From ec2d43acde8427c5a980678ec50a7ce3fffa3636 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Wed, 13 Dec 2023 15:43:35 -0500 Subject: [PATCH] WIP on SQLite/JSON data --- src/MyWebLog.Data/SQLite/Helpers.fs | 57 +++ src/MyWebLog.Data/SQLiteData.fs | 195 +++------ src/MyWebLog.Domain/DataTypes.fs | 637 ++++++++++++++-------------- src/MyWebLog.Domain/SupportTypes.fs | 515 +++++++++++----------- src/MyWebLog.Domain/ViewModels.fs | 259 +++++------ src/MyWebLog/Caches.fs | 2 +- src/MyWebLog/Handlers/Page.fs | 2 +- src/MyWebLog/Handlers/Post.fs | 2 +- src/MyWebLog/Handlers/Routes.fs | 2 +- 9 files changed, 819 insertions(+), 852 deletions(-) diff --git a/src/MyWebLog.Data/SQLite/Helpers.fs b/src/MyWebLog.Data/SQLite/Helpers.fs index 5224674..2a4f06a 100644 --- a/src/MyWebLog.Data/SQLite/Helpers.fs +++ b/src/MyWebLog.Data/SQLite/Helpers.fs @@ -2,6 +2,63 @@ [] module MyWebLog.Data.SQLite.Helpers +/// The table names used in the SQLite implementation +[] +module Table = + + /// Categories + [] + let Category = "category" + + /// Database Version + [] + let DbVersion = "db_version" + + /// Pages + [] + let Page = "page" + + /// Page Revisions + [] + let PageRevision = "page_revision" + + /// Posts + [] + let Post = "post" + + /// Post Comments + [] + let PostComment = "post_comment" + + /// Post Revisions + [] + let PostRevision = "post_revision" + + /// Tag/URL Mappings + [] + let TagMap = "tag_map" + + /// Themes + [] + let Theme = "theme" + + /// Theme Assets + [] + let ThemeAsset = "theme_asset" + + /// Uploads + [] + let Upload = "upload" + + /// Web Logs + [] + let WebLog = "web_log" + + /// Users + [] + let WebLogUser = "web_log_user" + + open System open Microsoft.Data.Sqlite open MyWebLog diff --git a/src/MyWebLog.Data/SQLiteData.fs b/src/MyWebLog.Data/SQLiteData.fs index c8248a9..d1a3aaf 100644 --- a/src/MyWebLog.Data/SQLiteData.fs +++ b/src/MyWebLog.Data/SQLiteData.fs @@ -27,17 +27,9 @@ type SQLiteData (conn : SqliteConnection, log : ILogger, ser : JsonS not (List.contains table tables) seq { // Theme tables - if needsTable "theme" then - "CREATE TABLE theme ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - version TEXT NOT NULL)" - if needsTable "theme_template" then - "CREATE TABLE theme_template ( - theme_id TEXT NOT NULL REFERENCES theme (id), - name TEXT NOT NULL, - template TEXT NOT NULL, - PRIMARY KEY (theme_id, name))" + if needsTable Table.Theme then + $"CREATE TABLE {Table.Theme} (data TEXT NOT NULL); + CREATE UNIQUE INDEX idx_{Table.Theme}_key ON {Table.Theme} (data ->> 'Id')"; if needsTable "theme_asset" then "CREATE TABLE theme_asset ( theme_id TEXT NOT NULL REFERENCES theme (id), @@ -46,139 +38,54 @@ type SQLiteData (conn : SqliteConnection, log : ILogger, ser : JsonS data BLOB NOT NULL, PRIMARY KEY (theme_id, path))" - // Web log tables - if needsTable "web_log" then - "CREATE TABLE web_log ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - slug TEXT NOT NULL, - subtitle TEXT, - default_page TEXT NOT NULL, - posts_per_page INTEGER NOT NULL, - theme_id TEXT NOT NULL REFERENCES theme (id), - url_base TEXT NOT NULL, - time_zone TEXT NOT NULL, - auto_htmx INTEGER NOT NULL DEFAULT 0, - uploads TEXT NOT NULL, - is_feed_enabled INTEGER NOT NULL DEFAULT 0, - feed_name TEXT NOT NULL, - items_in_feed INTEGER, - is_category_enabled INTEGER NOT NULL DEFAULT 0, - is_tag_enabled INTEGER NOT NULL DEFAULT 0, - copyright TEXT, - redirect_rules TEXT NOT NULL DEFAULT '[]'); - CREATE INDEX web_log_theme_idx ON web_log (theme_id)" - if needsTable "web_log_feed" then - "CREATE TABLE web_log_feed ( - id TEXT PRIMARY KEY, - web_log_id TEXT NOT NULL REFERENCES web_log (id), - source TEXT NOT NULL, - path TEXT NOT NULL, - podcast TEXT); - CREATE INDEX web_log_feed_web_log_idx ON web_log_feed (web_log_id)" + // Web log table + if needsTable Table.WebLog then + $"CREATE TABLE {Table.WebLog} (data TEXT NOT NULL); + CREATE UNIQUE INDEX idx_{Table.WebLog}_key ON {Table.WebLog} (data ->> 'Id')" // Category table - if needsTable "category" then - "CREATE TABLE category ( - id TEXT PRIMARY KEY, - web_log_id TEXT NOT NULL REFERENCES web_log (id), - name TEXT NOT NULL, - slug TEXT NOT NULL, - description TEXT, - parent_id TEXT); - CREATE INDEX category_web_log_idx ON category (web_log_id)" + if needsTable Table.Category then + $"CREATE TABLE {Table.Category} (data TEXT NOT NULL); + CREATE UNIQUE INDEX idx_{Table.Category}_key ON {Table.Category} (data -> 'Id'); + CREATE INDEX idx_{Table.Category}_web_log ON {Table.Category} (data ->> 'WebLogId')" // Web log user table - if needsTable "web_log_user" then - "CREATE TABLE web_log_user ( - id TEXT PRIMARY KEY, - web_log_id TEXT NOT NULL REFERENCES web_log (id), - email TEXT NOT NULL, - first_name TEXT NOT NULL, - last_name TEXT NOT NULL, - preferred_name TEXT NOT NULL, - password_hash TEXT NOT NULL, - url TEXT, - access_level TEXT NOT NULL, - created_on TEXT NOT NULL, - last_seen_on TEXT); - CREATE INDEX web_log_user_web_log_idx ON web_log_user (web_log_id); - CREATE INDEX web_log_user_email_idx ON web_log_user (web_log_id, email)" + if needsTable Table.WebLogUser then + $"CREATE TABLE web_log_user (data TEXT NOT NULL); + CREATE UNIQUE INDEX idx_{Table.WebLogUser}_key ON {Table.WebLogUser} (data ->> 'Id'); + CREATE INDEX idx_{Table.WebLogUser}_email ON {Table.WebLogUser} (data ->> 'WebLogId', data ->> 'Email')" // Page tables - if needsTable "page" then - "CREATE TABLE page ( - id TEXT PRIMARY KEY, - web_log_id TEXT NOT NULL REFERENCES web_log (id), - author_id TEXT NOT NULL REFERENCES web_log_user (id), - title TEXT NOT NULL, - permalink TEXT NOT NULL, - published_on TEXT NOT NULL, - updated_on TEXT NOT NULL, - is_in_page_list INTEGER NOT NULL DEFAULT 0, - template TEXT, - page_text TEXT NOT NULL, - meta_items TEXT); - CREATE INDEX page_web_log_idx ON page (web_log_id); - CREATE INDEX page_author_idx ON page (author_id); - CREATE INDEX page_permalink_idx ON page (web_log_id, permalink)" - if needsTable "page_permalink" then - "CREATE TABLE page_permalink ( - page_id TEXT NOT NULL REFERENCES page (id), - permalink TEXT NOT NULL, - PRIMARY KEY (page_id, permalink))" - if needsTable "page_revision" then + if needsTable Table.Page then + $"CREATE TABLE {Table.Page} (data TEXT NOT NULL); + CREATE UNIQUE INDEX idx_{Table.Page}_key ON {Table.Page} (data ->> 'Id'); + CREATE INDEX idx_{Table.Page}_author ON {Table.Page} (data ->> 'AuthorId'); + CREATE INDEX idx_{Table.Page}_permalink ON {Table.Page} (data ->> 'WebLogId', data ->> 'Permalink')" + if needsTable Table.PageRevision then "CREATE TABLE page_revision ( - page_id TEXT NOT NULL REFERENCES page (id), + page_id TEXT NOT NULL, as_of TEXT NOT NULL, revision_text TEXT NOT NULL, PRIMARY KEY (page_id, as_of))" // Post tables - if needsTable "post" then - "CREATE TABLE post ( - id TEXT PRIMARY KEY, - web_log_id TEXT NOT NULL REFERENCES web_log (id), - author_id TEXT NOT NULL REFERENCES web_log_user (id), - status TEXT NOT NULL, - title TEXT NOT NULL, - permalink TEXT NOT NULL, - published_on TEXT, - updated_on TEXT NOT NULL, - template TEXT, - post_text TEXT NOT NULL, - meta_items TEXT, - episode TEXT); - CREATE INDEX post_web_log_idx ON post (web_log_id); - CREATE INDEX post_author_idx ON post (author_id); - CREATE INDEX post_status_idx ON post (web_log_id, status, updated_on); - CREATE INDEX post_permalink_idx ON post (web_log_id, permalink)" - if needsTable "post_category" then - "CREATE TABLE post_category ( - post_id TEXT NOT NULL REFERENCES post (id), - category_id TEXT NOT NULL REFERENCES category (id), - PRIMARY KEY (post_id, category_id)); - CREATE INDEX post_category_category_idx ON post_category (category_id)" - if needsTable "post_tag" then - "CREATE TABLE post_tag ( - post_id TEXT NOT NULL REFERENCES post (id), - tag TEXT NOT NULL, - PRIMARY KEY (post_id, tag))" - if needsTable "post_permalink" then - "CREATE TABLE post_permalink ( - post_id TEXT NOT NULL REFERENCES post (id), - permalink TEXT NOT NULL, - PRIMARY KEY (post_id, permalink))" - if needsTable "post_revision" then - "CREATE TABLE post_revision ( - post_id TEXT NOT NULL REFERENCES post (id), + if needsTable Table.Post then + $"CREATE TABLE {Table.Post} (data TEXT NOT NULL); + CREATE UNIQUE INDEX idx_{Table.Post}_key ON {Table.Post} (data ->> 'Id'); + CREATE INDEX idx_{Table.Post}_author ON {Table.Post} (data ->> 'AuthorId'); + CREATE INDEX idx_{Table.Post}_status ON {Table.Post} (data ->> 'WebLogId', data ->> 'Status', data ->> 'UpdatedOn'); + CREATE INDEX idx_{Table.Post}_permalink ON {Table.Post} (data ->> 'WebLogId', data ->> 'Permalink')" + // TODO: index categories by post? + if needsTable Table.PostRevision then + $"CREATE TABLE {Table.PostRevision} ( + post_id TEXT NOT NULL, as_of TEXT NOT NULL, revision_text TEXT NOT NULL, PRIMARY KEY (post_id, as_of))" - if needsTable "post_comment" then - "CREATE TABLE post_comment ( + if needsTable Table.PostComment then + $"CREATE TABLE {Table.PostComment} ( id TEXT PRIMARY KEY, - post_id TEXT NOT NULL REFERENCES post(id), + post_id TEXT NOT NULL, in_reply_to_id TEXT, name TEXT NOT NULL, email TEXT NOT NULL, @@ -186,32 +93,28 @@ type SQLiteData (conn : SqliteConnection, log : ILogger, ser : JsonS status TEXT NOT NULL, posted_on TEXT NOT NULL, comment_text TEXT NOT NULL); - CREATE INDEX post_comment_post_idx ON post_comment (post_id)" + CREATE INDEX idx_{Table.PostComment}_post ON {Table.PostComment} (post_id)" // Tag map table - if needsTable "tag_map" then - "CREATE TABLE tag_map ( - id TEXT PRIMARY KEY, - web_log_id TEXT NOT NULL REFERENCES web_log (id), - tag TEXT NOT NULL, - url_value TEXT NOT NULL); - CREATE INDEX tag_map_web_log_idx ON tag_map (web_log_id)" + if needsTable Table.TagMap then + $"CREATE TABLE {Table.TagMap} (data TEXT NOT NULL); + CREATE UNIQUE INDEX idx_{Table.TagMap}_key ON {Table.TagMap} (data ->> 'Id'); + CREATE INDEX idx_{Table.TagMap}_tag ON {Table.TagMap} (data ->> 'WebLogId', data ->> 'UrlValue')"; // Uploaded file table - if needsTable "upload" then - "CREATE TABLE upload ( + if needsTable Table.Upload then + $"CREATE TABLE {Table.Upload} ( id TEXT PRIMARY KEY, - web_log_id TEXT NOT NULL REFERENCES web_log (id), + web_log_id TEXT NOT NULL, path TEXT NOT NULL, updated_on TEXT NOT NULL, data BLOB NOT NULL); - CREATE INDEX upload_web_log_idx ON upload (web_log_id); - CREATE INDEX upload_path_idx ON upload (web_log_id, path)" + CREATE INDEX idx_{Table.Upload}_path ON {Table.Upload} (web_log_id, path)" // Database version table - if needsTable "db_version" then - "CREATE TABLE db_version (id TEXT PRIMARY KEY); - INSERT INTO db_version VALUES ('v2')" + if needsTable Table.DbVersion then + $"CREATE TABLE {Table.DbVersion} (id TEXT PRIMARY KEY); + INSERT INTO {Table.DbVersion} VALUES ('v2.1')" } |> Seq.map (fun sql -> log.LogInformation $"Creating {(sql.Split ' ')[2]} table..." @@ -224,7 +127,7 @@ type SQLiteData (conn : SqliteConnection, log : ILogger, ser : JsonS /// Set the database version to the specified version let setDbVersion version = backgroundTask { use cmd = conn.CreateCommand () - cmd.CommandText <- $"DELETE FROM db_version; INSERT INTO db_version VALUES ('%s{version}')" + cmd.CommandText <- $"DELETE FROM {Table.DbVersion}; INSERT INTO {Table.DbVersion} VALUES ('%s{version}')" do! write cmd } @@ -600,7 +503,7 @@ type SQLiteData (conn : SqliteConnection, log : ILogger, ser : JsonS do! ensureTables () use cmd = conn.CreateCommand () - cmd.CommandText <- "SELECT id FROM db_version" + cmd.CommandText <- $"SELECT id FROM {Table.DbVersion}" use! rdr = cmd.ExecuteReaderAsync () do! migrate (if rdr.Read () then Some (Map.getString "id" rdr) else None) } diff --git a/src/MyWebLog.Domain/DataTypes.fs b/src/MyWebLog.Domain/DataTypes.fs index c547389..cae8b76 100644 --- a/src/MyWebLog.Domain/DataTypes.fs +++ b/src/MyWebLog.Domain/DataTypes.fs @@ -6,405 +6,404 @@ open NodaTime /// A category under which a post may be identified [] -type Category = - { /// The ID of the category - Id : CategoryId +type Category = { + /// The ID of the category + Id : CategoryId - /// The ID of the web log to which the category belongs - WebLogId : WebLogId + /// The ID of the web log to which the category belongs + WebLogId : WebLogId - /// The displayed name - Name : string + /// The displayed name + Name : string - /// The slug (used in category URLs) - Slug : string + /// The slug (used in category URLs) + Slug : string - /// A longer description of the category - Description : string option + /// A longer description of the category + Description : string option - /// The parent ID of this category (if a subcategory) - ParentId : CategoryId option - } + /// The parent ID of this category (if a subcategory) + ParentId : CategoryId option +} /// Functions to support categories module Category = /// An empty category - let empty = - { Id = CategoryId.empty - WebLogId = WebLogId.empty - Name = "" - Slug = "" - Description = None - ParentId = None - } + let empty = { + Id = CategoryId.empty + WebLogId = WebLogId.empty + Name = "" + Slug = "" + Description = None + ParentId = None + } /// A comment on a post [] -type Comment = - { /// The ID of the comment - Id : CommentId +type Comment = { + /// The ID of the comment + Id : CommentId - /// The ID of the post to which this comment applies - PostId : PostId + /// The ID of the post to which this comment applies + PostId : PostId - /// The ID of the comment to which this comment is a reply - InReplyToId : CommentId option + /// The ID of the comment to which this comment is a reply + InReplyToId : CommentId option - /// The name of the commentor - Name : string + /// The name of the commentor + Name : string - /// The e-mail address of the commentor - Email : string + /// The e-mail address of the commentor + Email : string - /// The URL of the commentor's personal website - Url : string option + /// The URL of the commentor's personal website + Url : string option - /// The status of the comment - Status : CommentStatus + /// The status of the comment + Status : CommentStatus - /// When the comment was posted - PostedOn : Instant + /// When the comment was posted + PostedOn : Instant - /// The text of the comment - Text : string - } + /// The text of the comment + Text : string +} /// Functions to support comments module Comment = /// An empty comment - let empty = - { Id = CommentId.empty - PostId = PostId.empty - InReplyToId = None - Name = "" - Email = "" - Url = None - Status = Pending - PostedOn = Noda.epoch - Text = "" - } + let empty = { + Id = CommentId.empty + PostId = PostId.empty + InReplyToId = None + Name = "" + Email = "" + Url = None + Status = Pending + PostedOn = Noda.epoch + Text = "" + } /// A page (text not associated with a date/time) [] -type Page = - { /// The ID of this page - Id : PageId +type Page = { + /// The ID of this page + Id : PageId - /// The ID of the web log to which this page belongs - WebLogId : WebLogId + /// The ID of the web log to which this page belongs + WebLogId : WebLogId - /// The ID of the author of this page - AuthorId : WebLogUserId + /// The ID of the author of this page + AuthorId : WebLogUserId - /// The title of the page - Title : string + /// The title of the page + Title : string - /// The link at which this page is displayed - Permalink : Permalink + /// The link at which this page is displayed + Permalink : Permalink - /// When this page was published - PublishedOn : Instant + /// When this page was published + PublishedOn : Instant - /// When this page was last updated - UpdatedOn : Instant + /// When this page was last updated + UpdatedOn : Instant - /// Whether this page shows as part of the web log's navigation - IsInPageList : bool + /// Whether this page shows as part of the web log's navigation + IsInPageList : bool - /// The template to use when rendering this page - Template : string option + /// The template to use when rendering this page + Template : string option - /// The current text of the page - Text : string + /// The current text of the page + Text : string - /// Metadata for this page - Metadata : MetaItem list - - /// Permalinks at which this page may have been previously served (useful for migrated content) - PriorPermalinks : Permalink list + /// Metadata for this page + Metadata : MetaItem list + + /// Permalinks at which this page may have been previously served (useful for migrated content) + PriorPermalinks : Permalink list - /// Revisions of this page - Revisions : Revision list - } + /// Revisions of this page + Revisions : Revision list +} /// Functions to support pages module Page = /// An empty page - let empty = - { Id = PageId.empty - WebLogId = WebLogId.empty - AuthorId = WebLogUserId.empty - Title = "" - Permalink = Permalink.empty - PublishedOn = Noda.epoch - UpdatedOn = Noda.epoch - IsInPageList = false - Template = None - Text = "" - Metadata = [] - PriorPermalinks = [] - Revisions = [] - } + let empty = { + Id = PageId.empty + WebLogId = WebLogId.empty + AuthorId = WebLogUserId.empty + Title = "" + Permalink = Permalink.empty + PublishedOn = Noda.epoch + UpdatedOn = Noda.epoch + IsInPageList = false + Template = None + Text = "" + Metadata = [] + PriorPermalinks = [] + Revisions = [] + } /// A web log post [] -type Post = - { /// The ID of this post - Id : PostId +type Post = { + /// The ID of this post + Id : PostId - /// The ID of the web log to which this post belongs - WebLogId : WebLogId + /// The ID of the web log to which this post belongs + WebLogId : WebLogId - /// The ID of the author of this post - AuthorId : WebLogUserId + /// The ID of the author of this post + AuthorId : WebLogUserId - /// The status - Status : PostStatus + /// The status + Status : PostStatus - /// The title - Title : string + /// The title + Title : string - /// The link at which the post resides - Permalink : Permalink + /// The link at which the post resides + Permalink : Permalink - /// The instant on which the post was originally published - PublishedOn : Instant option + /// The instant on which the post was originally published + PublishedOn : Instant option - /// The instant on which the post was last updated - UpdatedOn : Instant + /// The instant on which the post was last updated + UpdatedOn : Instant - /// The template to use in displaying the post - Template : string option - - /// The text of the post in HTML (ready to display) format - Text : string + /// The template to use in displaying the post + Template : string option + + /// The text of the post in HTML (ready to display) format + Text : string - /// The Ids of the categories to which this is assigned - CategoryIds : CategoryId list + /// The Ids of the categories to which this is assigned + CategoryIds : CategoryId list - /// The tags for the post - Tags : string list + /// The tags for the post + Tags : string list - /// Podcast episode information for this post - Episode : Episode option - - /// Metadata for the post - Metadata : MetaItem list - - /// Permalinks at which this post may have been previously served (useful for migrated content) - PriorPermalinks : Permalink list + /// Podcast episode information for this post + Episode : Episode option + + /// Metadata for the post + Metadata : MetaItem list + + /// Permalinks at which this post may have been previously served (useful for migrated content) + PriorPermalinks : Permalink list - /// The revisions for this post - Revisions : Revision list - } + /// The revisions for this post + Revisions : Revision list +} /// Functions to support posts module Post = /// An empty post - let empty = - { Id = PostId.empty - WebLogId = WebLogId.empty - AuthorId = WebLogUserId.empty - Status = Draft - Title = "" - Permalink = Permalink.empty - PublishedOn = None - UpdatedOn = Noda.epoch - Text = "" - Template = None - CategoryIds = [] - Tags = [] - Episode = None - Metadata = [] - PriorPermalinks = [] - Revisions = [] - } + let empty = { + Id = PostId.empty + WebLogId = WebLogId.empty + AuthorId = WebLogUserId.empty + Status = Draft + Title = "" + Permalink = Permalink.empty + PublishedOn = None + UpdatedOn = Noda.epoch + Text = "" + Template = None + CategoryIds = [] + Tags = [] + Episode = None + Metadata = [] + PriorPermalinks = [] + 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 - Id : TagMapId - - /// 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 - Tag : string - - /// The value by which the tag should be linked - UrlValue : string - } +type TagMap = { + /// The ID of this tag mapping + Id : TagMapId + + /// 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 + Tag : string + + /// The value by which the tag should be linked + UrlValue : string +} /// Functions to support tag mappings module TagMap = /// An empty tag mapping - let empty = - { Id = TagMapId.empty - WebLogId = WebLogId.empty - Tag = "" - UrlValue = "" - } + let empty = { + Id = TagMapId.empty + WebLogId = WebLogId.empty + Tag = "" + UrlValue = "" + } /// A theme -type Theme = - { /// The ID / path of the theme - Id : ThemeId - - /// A long name of the theme - Name : string - - /// The version of the theme - Version : string - - /// The templates for this theme - Templates: ThemeTemplate list - } +type Theme = { + /// The ID / path of the theme + Id : ThemeId + + /// A long name of the theme + Name : string + + /// The version of the theme + Version : string + + /// The templates for this theme + Templates: ThemeTemplate list +} /// Functions to support themes module Theme = /// An empty theme - let empty = - { Id = ThemeId "" - Name = "" - Version = "" - Templates = [] - } + let empty = { + Id = ThemeId "" + Name = "" + Version = "" + Templates = [] + } /// 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) - Id : ThemeAssetId - - /// The updated date (set from the file date from the ZIP archive) - UpdatedOn : Instant - - /// The data for the asset - Data : byte[] - } +type ThemeAsset = { + /// The ID of the asset (consists of theme and path) + Id : ThemeAssetId + + /// The updated date (set from the file date from the ZIP archive) + UpdatedOn : Instant + + /// The data for the asset + Data : byte[] +} /// Functions to support theme assets module ThemeAsset = /// An empty theme asset - let empty = - { Id = ThemeAssetId (ThemeId "", "") - UpdatedOn = Noda.epoch - Data = [||] - } + let empty = { + Id = ThemeAssetId (ThemeId "", "") + UpdatedOn = Noda.epoch + Data = [||] + } /// An uploaded file -type Upload = - { /// The ID of the upload - Id : UploadId - - /// The ID of the web log to which this upload belongs - WebLogId : WebLogId - - /// The link at which this upload is served - Path : Permalink - - /// The updated date/time for this upload - UpdatedOn : Instant - - /// The data for the upload - Data : byte[] - } +type Upload = { + /// The ID of the upload + Id : UploadId + + /// The ID of the web log to which this upload belongs + WebLogId : WebLogId + + /// The link at which this upload is served + Path : Permalink + + /// The updated date/time for this upload + UpdatedOn : Instant + + /// The data for the upload + Data : byte[] +} /// Functions to support uploaded files module Upload = /// An empty upload - let empty = - { Id = UploadId.empty - WebLogId = WebLogId.empty - Path = Permalink.empty - UpdatedOn = Noda.epoch - Data = [||] - } + let empty = { + Id = UploadId.empty + WebLogId = WebLogId.empty + Path = Permalink.empty + UpdatedOn = Noda.epoch + Data = [||] + } /// A web log [] -type WebLog = - { /// The ID of the web log - Id : WebLogId +type WebLog = { + /// The ID of the web log + Id : WebLogId - /// The name of the web log - Name : string + /// The name of the web log + Name : string - /// The slug of the web log - Slug : string - - /// A subtitle for the web log - Subtitle : string option + /// The slug of the web log + Slug : string + + /// A subtitle for the web log + Subtitle : string option - /// The default page ("posts" or a page Id) - DefaultPage : string + /// The default page ("posts" or a page Id) + DefaultPage : string - /// The number of posts to display on pages of posts - PostsPerPage : int + /// The number of posts to display on pages of posts + PostsPerPage : int - /// The ID of the theme (also the path within /themes) - ThemeId : ThemeId + /// The ID of the theme (also the path within /themes) + ThemeId : ThemeId - /// The URL base - UrlBase : string + /// The URL base + UrlBase : string - /// The time zone in which dates/times should be displayed - TimeZone : string - - /// The RSS options for this web log - Rss : RssOptions - - /// Whether to automatically load htmx - AutoHtmx : bool - - /// Where uploads are placed - Uploads : UploadDestination + /// The time zone in which dates/times should be displayed + TimeZone : string + + /// The RSS options for this web log + Rss : RssOptions + + /// Whether to automatically load htmx + AutoHtmx : bool + + /// Where uploads are placed + Uploads : UploadDestination - /// Redirect rules for this weblog - RedirectRules : RedirectRule list - } + /// Redirect rules for this weblog + RedirectRules : RedirectRule list +} /// Functions to support web logs module WebLog = /// An empty web log - let empty = - { Id = WebLogId.empty - Name = "" - Slug = "" - Subtitle = None - DefaultPage = "" - PostsPerPage = 10 - ThemeId = ThemeId "default" - UrlBase = "" - TimeZone = "" - Rss = RssOptions.empty - AutoHtmx = false - Uploads = Database - RedirectRules = [] - } + let empty = { + Id = WebLogId.empty + Name = "" + Slug = "" + Subtitle = None + DefaultPage = "" + PostsPerPage = 10 + ThemeId = ThemeId "default" + UrlBase = "" + TimeZone = "" + Rss = RssOptions.empty + AutoHtmx = false + Uploads = Database + RedirectRules = [] + } /// Get the host (including scheme) and extra path from the URL base let hostAndPath webLog = let scheme = webLog.UrlBase.Split "://" let host = scheme[1].Split "/" - $"{scheme[0]}://{host[0]}", if host.Length > 1 then $"""/{String.Join ("/", host |> Array.skip 1)}""" else "" + $"{scheme[0]}://{host[0]}", if host.Length > 1 then $"""/{String.Join("/", host |> Array.skip 1)}""" else "" /// Generate an absolute URL for the given link let absoluteUrl webLog permalink = @@ -418,71 +417,71 @@ module WebLog = /// Convert an Instant (UTC reference) to the web log's local date/time let localTime webLog (date : Instant) = match DateTimeZoneProviders.Tzdb[webLog.TimeZone] with - | null -> date.ToDateTimeUtc () - | tz -> date.InZone(tz).ToDateTimeUnspecified () + | null -> date.ToDateTimeUtc() + | tz -> date.InZone(tz).ToDateTimeUnspecified() /// A user of the web log [] -type WebLogUser = - { /// The ID of the user - Id : WebLogUserId +type WebLogUser = { + /// The ID of the user + Id : WebLogUserId - /// The ID of the web log to which this user belongs - WebLogId : WebLogId + /// The ID of the web log to which this user belongs + WebLogId : WebLogId - /// The user name (e-mail address) - Email : string + /// The user name (e-mail address) + Email : string - /// The user's first name - FirstName : string + /// The user's first name + FirstName : string - /// The user's last name - LastName : string + /// The user's last name + LastName : string - /// The user's preferred name - PreferredName : string + /// The user's preferred name + PreferredName : string - /// The hash of the user's password - PasswordHash : string + /// The hash of the user's password + PasswordHash : string - /// The URL of the user's personal site - Url : string option + /// The URL of the user's personal site + Url : string option - /// The user's access level - AccessLevel : AccessLevel - - /// When the user was created - CreatedOn : Instant - - /// When the user last logged on - LastSeenOn : Instant option - } + /// The user's access level + AccessLevel : AccessLevel + + /// When the user was created + CreatedOn : Instant + + /// When the user last logged on + LastSeenOn : Instant option +} /// Functions to support web log users module WebLogUser = /// An empty web log user - let empty = - { Id = WebLogUserId.empty - WebLogId = WebLogId.empty - Email = "" - FirstName = "" - LastName = "" - PreferredName = "" - PasswordHash = "" - Url = None - AccessLevel = Author - CreatedOn = Noda.epoch - LastSeenOn = None - } + let empty = { + Id = WebLogUserId.empty + WebLogId = WebLogId.empty + Email = "" + FirstName = "" + LastName = "" + PreferredName = "" + PasswordHash = "" + Url = None + AccessLevel = Author + CreatedOn = Noda.epoch + LastSeenOn = None + } /// Get the user's displayed name let displayName user = let name = seq { match user.PreferredName with "" -> user.FirstName | n -> n; " "; user.LastName } |> Seq.reduce (+) - name.Trim () + name.Trim() /// Does a user have the required access level? let hasAccess level user = diff --git a/src/MyWebLog.Domain/SupportTypes.fs b/src/MyWebLog.Domain/SupportTypes.fs index fb277ba..c3e0fe1 100644 --- a/src/MyWebLog.Domain/SupportTypes.fs +++ b/src/MyWebLog.Domain/SupportTypes.fs @@ -10,7 +10,7 @@ module private Helpers = /// Create a new ID (short GUID) // https://www.madskristensen.net/blog/A-shorter-and-URL-friendly-GUID let newId () = - Convert.ToBase64String(Guid.NewGuid().ToByteArray ()).Replace('/', '_').Replace('+', '-').Substring (0, 22) + Convert.ToBase64String(Guid.NewGuid().ToByteArray ()).Replace('/', '_').Replace('+', '-')[..22] /// Functions to support NodaTime manipulation @@ -21,19 +21,18 @@ module Noda = /// The Unix epoch let epoch = Instant.FromUnixTimeSeconds 0L - /// Truncate an instant to remove fractional seconds let toSecondsPrecision (value : Instant) = - Instant.FromUnixTimeSeconds (value.ToUnixTimeSeconds ()) + Instant.FromUnixTimeSeconds(value.ToUnixTimeSeconds()) /// The current Instant, with fractional seconds truncated - let now () = - toSecondsPrecision (clock.GetCurrentInstant ()) + let now = + clock.GetCurrentInstant >> toSecondsPrecision /// Convert a date/time to an Instant with whole seconds let fromDateTime (dt : DateTime) = - toSecondsPrecision (Instant.FromDateTimeUtc (DateTime (dt.Ticks, DateTimeKind.Utc))) + Instant.FromDateTimeUtc(DateTime(dt.Ticks, DateTimeKind.Utc)) |> toSecondsPrecision /// A user's access level @@ -94,7 +93,7 @@ module CategoryId = let toString = function CategoryId ci -> ci /// Create a new category ID - let create () = CategoryId (newId ()) + let create = newId >> CategoryId /// An identifier for a comment @@ -110,7 +109,7 @@ module CommentId = let toString = function CommentId ci -> ci /// Create a new comment ID - let create () = CommentId (newId ()) + let create = newId >> CommentId /// Statuses for post comments @@ -134,7 +133,7 @@ module CommentStatus = | "Approved" -> Approved | "Pending" -> Pending | "Spam" -> Spam - | it -> invalidOp $"{it} is not a valid post status" + | it -> invalidArg "status" $"{it} is not a valid comment status" /// Valid values for the iTunes explicit rating @@ -158,127 +157,127 @@ module ExplicitRating = | "yes" -> Yes | "no" -> No | "clean" -> Clean - | x -> raise (invalidArg "rating" $"{x} is not a valid explicit rating") + | x -> invalidArg "rating" $"{x} is not a valid explicit rating" /// A location (specified by Podcast Index) -type Location = - { /// The name of the location (free-form text) - Name : string +type Location = { + /// The name of the location (free-form text) + Name : string - /// A geographic coordinate string (RFC 5870) - Geo : string option + /// A geographic coordinate string (RFC 5870) + Geo : string option - /// An OpenStreetMap query - Osm : string option - } + /// An OpenStreetMap query + Osm : string option +} /// A chapter in a podcast episode -type Chapter = - { /// The start time for the chapter - StartTime : Duration +type Chapter = { + /// The start time for the chapter + StartTime : Duration - /// The title for this chapter - Title : string option + /// The title for this chapter + Title : string option - /// A URL for an image for this chapter - ImageUrl : string option + /// A URL for an image for this chapter + ImageUrl : string option - /// Whether this chapter is hidden - IsHidden : bool option + /// Whether this chapter is hidden + IsHidden : bool option - /// The episode end time for the chapter - EndTime : Duration option + /// The episode end time for the chapter + EndTime : Duration option - /// A location that applies to a chapter - Location : Location option - } + /// A location that applies to a chapter + Location : Location option +} open NodaTime.Text /// A podcast episode -type Episode = - { /// The URL to the media file for the episode (may be permalink) - Media : string - - /// The length of the media file, in bytes - Length : int64 - - /// The duration of the episode - Duration : Duration option - - /// 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) - ImageUrl : string option - - /// A subtitle for this episode - Subtitle : string option - - /// This episode's explicit rating (overrides podcast rating if present) - Explicit : ExplicitRating option - - /// Chapters for this episode - Chapters : Chapter list option +type Episode = { + /// The URL to the media file for the episode (may be permalink) + Media : string + + /// The length of the media file, in bytes + Length : int64 + + /// The duration of the episode + Duration : Duration option + + /// 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) + ImageUrl : string option + + /// A subtitle for this episode + Subtitle : string option + + /// This episode's explicit rating (overrides podcast rating if present) + Explicit : ExplicitRating option + + /// Chapters for this episode + Chapters : Chapter list option - /// A link to a chapter file - ChapterFile : string option - - /// The MIME type for the chapter file - ChapterType : string option - - /// The URL for the transcript of the episode (may be permalink) - TranscriptUrl : string option - - /// The MIME type of the transcript - TranscriptType : string option - - /// 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 - TranscriptCaptions : bool option - - /// The season number (for serialized podcasts) - SeasonNumber : int option - - /// A description of the season - SeasonDescription : string option - - /// The episode number - EpisodeNumber : double option - - /// A description of the episode - EpisodeDescription : string option - } + /// A link to a chapter file + ChapterFile : string option + + /// The MIME type for the chapter file + ChapterType : string option + + /// The URL for the transcript of the episode (may be permalink) + TranscriptUrl : string option + + /// The MIME type of the transcript + TranscriptType : string option + + /// 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 + TranscriptCaptions : bool option + + /// The season number (for serialized podcasts) + SeasonNumber : int option + + /// A description of the season + SeasonDescription : string option + + /// The episode number + EpisodeNumber : double option + + /// A description of the episode + EpisodeDescription : string option +} /// Functions to support episodes module Episode = /// An empty episode - let empty = - { Media = "" - Length = 0L - Duration = None - MediaType = None - ImageUrl = None - Subtitle = None - Explicit = None - Chapters = None - ChapterFile = None - ChapterType = None - TranscriptUrl = None - TranscriptType = None - TranscriptLang = None - TranscriptCaptions = None - SeasonNumber = None - SeasonDescription = None - EpisodeNumber = None - EpisodeDescription = None - } + let empty = { + Media = "" + Length = 0L + Duration = None + MediaType = None + ImageUrl = None + Subtitle = None + Explicit = None + Chapters = None + ChapterFile = None + ChapterType = None + TranscriptUrl = None + TranscriptType = None + TranscriptLang = None + TranscriptCaptions = None + SeasonNumber = None + SeasonDescription = None + EpisodeNumber = None + EpisodeDescription = None + } /// Format a duration for an episode let formatDuration ep = @@ -299,7 +298,7 @@ type MarkupText = module MarkupText = /// Pipeline with most extensions enabled - let private _pipeline = MarkdownPipelineBuilder().UseSmartyPants().UseAdvancedExtensions().UseColorCode().Build () + let private _pipeline = MarkdownPipelineBuilder().UseSmartyPants().UseAdvancedExtensions().UseColorCode().Build() /// Get the source type for the markup text let sourceType = function Markdown _ -> "Markdown" | Html _ -> "HTML" @@ -311,25 +310,25 @@ module MarkupText = let toString it = $"{sourceType it}: {text it}" /// Get the HTML representation of the markup text - let toHtml = function Markdown text -> Markdown.ToHtml (text, _pipeline) | Html text -> text + let toHtml = function Markdown text -> Markdown.ToHtml(text, _pipeline) | Html text -> text /// Parse a string into a MarkupText instance let parse (it : string) = match it with - | text when text.StartsWith "Markdown: " -> Markdown (text.Substring 10) - | text when text.StartsWith "HTML: " -> Html (text.Substring 6) + | text when text.StartsWith "Markdown: " -> Markdown text[10..] + | text when text.StartsWith "HTML: " -> Html text[6..] | text -> invalidOp $"Cannot derive type of text ({text})" /// An item of metadata [] -type MetaItem = - { /// The name of the metadata value - Name : string - - /// The metadata value - Value : string - } +type MetaItem = { + /// The name of the metadata value + Name : string + + /// The metadata value + Value : string +} /// Functions to support metadata items module MetaItem = @@ -340,22 +339,20 @@ module MetaItem = /// A revision of a page or post [] -type Revision = - { /// When this revision was saved - AsOf : Instant +type Revision = { + /// When this revision was saved + AsOf : Instant - /// The text of the revision - Text : MarkupText - } + /// The text of the revision + Text : MarkupText +} /// Functions to support revisions module Revision = /// An empty revision let empty = - { AsOf = Noda.epoch - Text = Html "" - } + { AsOf = Noda.epoch; Text = Html "" } /// A permanent link @@ -384,7 +381,7 @@ module PageId = let toString = function PageId pi -> pi /// Create a new page ID - let create () = PageId (newId ()) + let create = newId >> PageId /// PodcastIndex.org podcast:medium allowed values @@ -421,7 +418,7 @@ module PodcastMedium = | "audiobook" -> Audiobook | "newsletter" -> Newsletter | "blog" -> Blog - | it -> invalidOp $"{it} is not a valid podcast medium" + | it -> invalidArg "medium" $"{it} is not a valid podcast medium" /// Statuses for posts @@ -442,7 +439,7 @@ module PostStatus = match value with | "Draft" -> Draft | "Published" -> Published - | it -> invalidOp $"{it} is not a valid post status" + | it -> invalidArg "status" $"{it} is not a valid post status" /// An identifier for a post @@ -458,30 +455,30 @@ module PostId = let toString = function PostId pi -> pi /// Create a new post ID - let create () = PostId (newId ()) + let create = newId >> PostId /// A redirection for a previously valid URL -type RedirectRule = - { /// The From string or pattern - From : string - - /// The To string or pattern - To : string - - /// Whether to use regular expressions on this rule - IsRegex : bool - } +type RedirectRule = { + /// The From string or pattern + From : string + + /// The To string or pattern + To : string + + /// Whether to use regular expressions on this rule + IsRegex : bool +} /// Functions to support redirect rules module RedirectRule = /// An empty redirect rule - let empty = - { From = "" - To = "" - IsRegex = false - } + let empty = { + From = "" + To = "" + IsRegex = false + } /// An identifier for a custom feed @@ -497,7 +494,7 @@ module CustomFeedId = let toString = function CustomFeedId pi -> pi /// Create a new custom feed ID - let create () = CustomFeedId (newId ()) + let create = newId >> CustomFeedId /// The source for a custom feed @@ -525,122 +522,122 @@ module CustomFeedSource = /// Options for a feed that describes a podcast -type PodcastOptions = - { /// The title of the podcast - Title : string - - /// A subtitle for the podcast - Subtitle : string option - - /// The number of items in the podcast feed - ItemsInFeed : int - - /// A summary of the podcast (iTunes field) - Summary : string - - /// The display name of the podcast author (iTunes field) - DisplayedAuthor : string - - /// The e-mail address of the user who registered the podcast at iTunes - Email : string - - /// The link to the image for the podcast - ImageUrl : Permalink - - /// 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) - Explicit : ExplicitRating - - /// 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 - PodcastGuid : Guid option - - /// 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 - FundingText : string option - - /// The medium (what the podcast IS, not what it is ABOUT) - Medium : PodcastMedium option - } +type PodcastOptions = { + /// The title of the podcast + Title : string + + /// A subtitle for the podcast + Subtitle : string option + + /// The number of items in the podcast feed + ItemsInFeed : int + + /// A summary of the podcast (iTunes field) + Summary : string + + /// The display name of the podcast author (iTunes field) + DisplayedAuthor : string + + /// The e-mail address of the user who registered the podcast at iTunes + Email : string + + /// The link to the image for the podcast + ImageUrl : Permalink + + /// 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) + Explicit : ExplicitRating + + /// 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 + PodcastGuid : Guid option + + /// 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 + FundingText : string option + + /// The medium (what the podcast IS, not what it is ABOUT) + Medium : PodcastMedium option +} /// A custom feed -type CustomFeed = - { /// The ID of the custom feed - Id : CustomFeedId - - /// The source for the custom feed - Source : CustomFeedSource - - /// The path for the custom feed - Path : Permalink - - /// Podcast options, if the feed defines a podcast - Podcast : PodcastOptions option - } +type CustomFeed = { + /// The ID of the custom feed + Id : CustomFeedId + + /// The source for the custom feed + Source : CustomFeedSource + + /// The path for the custom feed + Path : Permalink + + /// Podcast options, if the feed defines a podcast + Podcast : PodcastOptions option +} /// Functions to support custom feeds module CustomFeed = /// An empty custom feed - let empty = - { Id = CustomFeedId "" - Source = Category (CategoryId "") - Path = Permalink "" - Podcast = None - } + let empty = { + Id = CustomFeedId "" + Source = Category (CategoryId "") + Path = Permalink "" + Podcast = None + } /// Really Simple Syndication (RSS) options for this web log [] -type RssOptions = - { /// Whether the site feed of posts is enabled - IsFeedEnabled : bool - - /// The name of the file generated for the site feed - FeedName : string - - /// Override the "posts per page" setting for the site feed - ItemsInFeed : int option - - /// Whether feeds are enabled for all categories - IsCategoryEnabled : bool - - /// Whether feeds are enabled for all tags - IsTagEnabled : bool - - /// A copyright string to be placed in all feeds - Copyright : string option - - /// Custom feeds for this web log - CustomFeeds: CustomFeed list - } +type RssOptions = { + /// Whether the site feed of posts is enabled + IsFeedEnabled : bool + + /// The name of the file generated for the site feed + FeedName : string + + /// Override the "posts per page" setting for the site feed + ItemsInFeed : int option + + /// Whether feeds are enabled for all categories + IsCategoryEnabled : bool + + /// Whether feeds are enabled for all tags + IsTagEnabled : bool + + /// A copyright string to be placed in all feeds + Copyright : string option + + /// Custom feeds for this web log + CustomFeeds: CustomFeed list +} /// Functions to support RSS options module RssOptions = /// An empty set of RSS options - let empty = - { IsFeedEnabled = true - FeedName = "feed.xml" - ItemsInFeed = None - IsCategoryEnabled = true - IsTagEnabled = true - Copyright = None - CustomFeeds = [] - } + let empty = { + IsFeedEnabled = true + FeedName = "feed.xml" + ItemsInFeed = None + IsCategoryEnabled = true + IsTagEnabled = true + Copyright = None + CustomFeeds = [] + } /// An identifier for a tag mapping @@ -656,7 +653,7 @@ module TagMapId = let toString = function TagMapId tmi -> tmi /// Create a new tag mapping ID - let create () = TagMapId (newId ()) + let create = newId >> TagMapId /// An identifier for a theme (represents its path) @@ -683,22 +680,20 @@ module ThemeAssetId = /// A template for a theme -type ThemeTemplate = - { /// The name of the template - Name : string - - /// The text of the template - Text : string - } +type ThemeTemplate = { + /// The name of the template + Name : string + + /// The text of the template + Text : string +} /// Functions to support theme templates module ThemeTemplate = /// An empty theme template let empty = - { Name = "" - Text = "" - } + { Name = ""; Text = "" } /// Where uploads should be placed @@ -717,7 +712,7 @@ module UploadDestination = match value with | "Database" -> Database | "Disk" -> Disk - | it -> invalidOp $"{it} is not a valid upload destination" + | it -> invalidArg "destination" $"{it} is not a valid upload destination" /// An identifier for an upload @@ -733,7 +728,7 @@ module UploadId = let toString = function UploadId ui -> ui /// Create a new upload ID - let create () = UploadId (newId ()) + let create = newId >> UploadId /// An identifier for a web log @@ -749,7 +744,7 @@ module WebLogId = let toString = function WebLogId wli -> wli /// Create a new web log ID - let create () = WebLogId (newId ()) + let create = newId >> WebLogId @@ -766,6 +761,6 @@ module WebLogUserId = let toString = function WebLogUserId wli -> wli /// Create a new web log user ID - let create () = WebLogUserId (newId ()) + let create = newId >> WebLogUserId diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index 86f00a7..a0ebb3f 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -10,7 +10,7 @@ module private Helpers = /// Create a string option if a string is blank let noneIfBlank (it : string) = - match (defaultArg (Option.ofObj it) "").Trim () with "" -> None | trimmed -> Some trimmed + match (defaultArg (Option.ofObj it) "").Trim() with "" -> None | trimmed -> Some trimmed /// Helper functions that are needed outside this file @@ -26,67 +26,70 @@ module PublicHelpers = /// The model used to display the admin dashboard [] -type DashboardModel = - { /// The number of published posts - Posts : int +type DashboardModel = { + /// The number of published posts + Posts : int - /// The number of post drafts - Drafts : int + /// The number of post drafts + Drafts : int - /// The number of pages - Pages : int + /// The number of pages + Pages : int - /// The number of pages in the page list - ListedPages : int + /// The number of pages in the page list + ListedPages : int - /// The number of categories - Categories : int + /// The number of categories + Categories : int - /// The top-level categories - TopLevelCategories : int - } + /// The top-level categories + TopLevelCategories : int +} /// Details about a category, used to display category lists [] -type DisplayCategory = - { /// The ID of the category - Id : string - - /// The slug for the category - Slug : string - - /// The name of the category - Name : string - - /// A description of the category - Description : string option - - /// The parent category names for this (sub)category - ParentNames : string[] - - /// The number of posts in this category - PostCount : int - } +type DisplayCategory = { + /// The ID of the category + Id : string + + /// The slug for the category + Slug : string + + /// The name of the category + Name : string + + /// A description of the category + Description : string option + + /// The parent category names for this (sub)category + ParentNames : string[] + + /// The number of posts in this category + PostCount : int +} /// A display version of a custom feed definition -type DisplayCustomFeed = - { /// The ID of the custom feed - Id : string - - /// The source of the custom feed - Source : string - - /// The relative path at which the custom feed is served - Path : string - - /// Whether this custom feed is for a podcast - IsPodcast : bool - } +type DisplayCustomFeed = { + /// The ID of the custom feed + Id : string + + /// The source of the custom feed + Source : string + + /// The relative path at which the custom feed is served + Path : string + + /// Whether this custom feed is for a podcast + IsPodcast : bool +} + +/// Support functions for custom feed displays +module DisplayCustomFeed = /// Create a display version from a custom feed - static member fromFeed (cats : DisplayCategory[]) (feed : CustomFeed) : DisplayCustomFeed = + let fromFeed (cats : DisplayCategory[]) (feed : CustomFeed) : DisplayCustomFeed = let source = match feed.Source with | Category (CategoryId catId) -> $"Category: {(cats |> Array.find (fun cat -> cat.Id = catId)).Name}" @@ -133,7 +136,7 @@ type DisplayPage = } /// Create a minimal display page (no text or metadata) from a database page - static member fromPageMinimal webLog (page : Page) = + static member FromPageMinimal webLog (page : Page) = let pageId = PageId.toString page.Id { Id = pageId AuthorId = WebLogUserId.toString page.AuthorId @@ -148,7 +151,7 @@ type DisplayPage = } /// Create a display page from a database page - static member fromPage webLog (page : Page) = + static member FromPage webLog (page : Page) = let _, extra = WebLog.hostAndPath webLog let pageId = PageId.toString page.Id { Id = pageId @@ -166,20 +169,22 @@ type DisplayPage = /// Information about a revision used for display [] -type DisplayRevision = - { /// The as-of date/time for the revision - AsOf : DateTime - - /// The as-of date/time for the revision in the web log's local time zone - AsOfLocal : DateTime - - /// The format of the text of the revision - Format : string - } -with +type DisplayRevision = { + /// The as-of date/time for the revision + AsOf : DateTime + + /// The as-of date/time for the revision in the web log's local time zone + AsOfLocal : DateTime + + /// The format of the text of the revision + Format : string +} +/// Functions to support displaying revisions +module DisplayRevision = + /// Create a display revision from an actual revision - static member fromRevision webLog (rev : Revision) = + let fromRevision webLog (rev : Revision) = { AsOf = rev.AsOf.ToDateTimeUtc () AsOfLocal = WebLog.localTime webLog rev.AsOf Format = MarkupText.sourceType rev.Text @@ -190,29 +195,31 @@ open System.IO /// Information about a theme used for display [] -type DisplayTheme = - { /// The ID / path slug of the theme - Id : string - - /// The name of the theme - Name : string - - /// The version of the theme - Version : string - - /// How many templates are contained in the theme - TemplateCount : int - - /// Whether the theme is in use by any web logs - IsInUse : bool - - /// Whether the theme .zip file exists on the filesystem - IsOnDisk : bool - } -with +type DisplayTheme = { + /// The ID / path slug of the theme + Id : string + + /// The name of the theme + Name : string + + /// The version of the theme + Version : string + + /// How many templates are contained in the theme + TemplateCount : int + + /// Whether the theme is in use by any web logs + IsInUse : bool + + /// Whether the theme .zip file exists on the filesystem + IsOnDisk : bool +} + +/// Functions to support displaying themes +module DisplayTheme = /// Create a display theme from a theme - static member fromTheme inUseFunc (theme : Theme) = + let fromTheme inUseFunc (theme : Theme) = { Id = ThemeId.toString theme.Id Name = theme.Name Version = theme.Version @@ -224,25 +231,28 @@ with /// Information about an uploaded file used for display [] -type DisplayUpload = - { /// The ID of the uploaded file - Id : string - - /// The name of the uploaded file - Name : string - - /// The path at which the file is served - Path : string - - /// The date/time the file was updated - UpdatedOn : DateTime option - - /// The source for this file (created from UploadDestination DU) - Source : string - } +type DisplayUpload = { + /// The ID of the uploaded file + Id : string + + /// The name of the uploaded file + Name : string + + /// The path at which the file is served + Path : string + + /// The date/time the file was updated + UpdatedOn : DateTime option + + /// The source for this file (created from UploadDestination DU) + Source : string +} + +/// Functions to support displaying uploads +module DisplayUpload = /// Create a display uploaded file - static member fromUpload webLog source (upload : Upload) = + let fromUpload webLog source (upload : Upload) = let path = Permalink.toString upload.Path let name = Path.GetFileName path { Id = UploadId.toString upload.Id @@ -255,37 +265,40 @@ type DisplayUpload = /// View model to display a user's information [] -type DisplayUser = - { /// The ID of the user - Id : string +type DisplayUser = { + /// The ID of the user + Id : string - /// The user name (e-mail address) - Email : string + /// The user name (e-mail address) + Email : string - /// The user's first name - FirstName : string + /// The user's first name + FirstName : string - /// The user's last name - LastName : string + /// The user's last name + LastName : string - /// The user's preferred name - PreferredName : string + /// The user's preferred name + PreferredName : string - /// The URL of the user's personal site - Url : string + /// The URL of the user's personal site + Url : string - /// The user's access level - AccessLevel : string - - /// When the user was created - CreatedOn : DateTime - - /// When the user last logged on - LastSeenOn : Nullable - } + /// The user's access level + AccessLevel : string + + /// When the user was created + CreatedOn : DateTime + + /// When the user last logged on + LastSeenOn : Nullable +} + +/// Functions to support displaying a user's information +module DisplayUser = /// Construct a displayed user from a web log user - static member fromUser webLog (user : WebLogUser) = + let fromUser webLog (user : WebLogUser) = { Id = WebLogUserId.toString user.Id Email = user.Email FirstName = user.FirstName diff --git a/src/MyWebLog/Caches.fs b/src/MyWebLog/Caches.fs index 2b66b59..cfb0e0f 100644 --- a/src/MyWebLog/Caches.fs +++ b/src/MyWebLog/Caches.fs @@ -131,7 +131,7 @@ module PageListCache = let private fillPages (webLog : WebLog) pages = _cache[webLog.Id] <- pages - |> List.map (fun pg -> DisplayPage.fromPage webLog { pg with Text = "" }) + |> List.map (fun pg -> DisplayPage.FromPage webLog { pg with Text = "" }) |> Array.ofList /// Are there pages cached for this web log? diff --git a/src/MyWebLog/Handlers/Page.fs b/src/MyWebLog/Handlers/Page.fs index 6ddeae8..cfdebbd 100644 --- a/src/MyWebLog/Handlers/Page.fs +++ b/src/MyWebLog/Handlers/Page.fs @@ -15,7 +15,7 @@ let all pageNbr : HttpHandler = requireAccess Author >=> fun next ctx -> task { |> addToHash "pages" (pages |> Seq.ofList |> Seq.truncate 25 - |> Seq.map (DisplayPage.fromPageMinimal ctx.WebLog) + |> Seq.map (DisplayPage.FromPageMinimal ctx.WebLog) |> List.ofSeq) |> addToHash "page_nbr" pageNbr |> addToHash "prev_page" (if pageNbr = 2 then "" else $"/page/{pageNbr - 1}") diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index c39dc86..087f66c 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -200,7 +200,7 @@ let home : HttpHandler = fun next ctx -> task { | Some page -> return! hashForPage page.Title - |> addToHash "page" (DisplayPage.fromPage webLog page) + |> addToHash "page" (DisplayPage.FromPage webLog page) |> addToHash ViewContext.IsHome true |> themedView (defaultArg page.Template "single-page") next ctx | None -> return! Error.notFound next ctx diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index 3b8f74d..5c6d371 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -40,7 +40,7 @@ module CatchAll = debug (fun () -> "Found page by permalink") yield fun next ctx -> hashForPage page.Title - |> addToHash "page" (DisplayPage.fromPage webLog page) + |> addToHash "page" (DisplayPage.FromPage webLog page) |> addToHash ViewContext.IsPage true |> themedView (defaultArg page.Template "single-page") next ctx | None -> ()