WIP on SQLite/JSON data

This commit is contained in:
Daniel J. Summers 2023-12-13 15:43:35 -05:00
parent 715e545ed5
commit ec2d43acde
9 changed files with 819 additions and 852 deletions

View File

@ -2,6 +2,63 @@
[<AutoOpen>] [<AutoOpen>]
module MyWebLog.Data.SQLite.Helpers module MyWebLog.Data.SQLite.Helpers
/// The table names used in the SQLite implementation
[<RequireQualifiedAccess>]
module Table =
/// Categories
[<Literal>]
let Category = "category"
/// Database Version
[<Literal>]
let DbVersion = "db_version"
/// Pages
[<Literal>]
let Page = "page"
/// Page Revisions
[<Literal>]
let PageRevision = "page_revision"
/// Posts
[<Literal>]
let Post = "post"
/// Post Comments
[<Literal>]
let PostComment = "post_comment"
/// Post Revisions
[<Literal>]
let PostRevision = "post_revision"
/// Tag/URL Mappings
[<Literal>]
let TagMap = "tag_map"
/// Themes
[<Literal>]
let Theme = "theme"
/// Theme Assets
[<Literal>]
let ThemeAsset = "theme_asset"
/// Uploads
[<Literal>]
let Upload = "upload"
/// Web Logs
[<Literal>]
let WebLog = "web_log"
/// Users
[<Literal>]
let WebLogUser = "web_log_user"
open System open System
open Microsoft.Data.Sqlite open Microsoft.Data.Sqlite
open MyWebLog open MyWebLog

View File

@ -27,17 +27,9 @@ type SQLiteData (conn : SqliteConnection, log : ILogger<SQLiteData>, ser : JsonS
not (List.contains table tables) not (List.contains table tables)
seq { seq {
// Theme tables // Theme tables
if needsTable "theme" then if needsTable Table.Theme then
"CREATE TABLE theme ( $"CREATE TABLE {Table.Theme} (data TEXT NOT NULL);
id TEXT PRIMARY KEY, CREATE UNIQUE INDEX idx_{Table.Theme}_key ON {Table.Theme} (data ->> 'Id')";
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 "theme_asset" then if needsTable "theme_asset" then
"CREATE TABLE theme_asset ( "CREATE TABLE theme_asset (
theme_id TEXT NOT NULL REFERENCES theme (id), theme_id TEXT NOT NULL REFERENCES theme (id),
@ -46,139 +38,54 @@ type SQLiteData (conn : SqliteConnection, log : ILogger<SQLiteData>, ser : JsonS
data BLOB NOT NULL, data BLOB NOT NULL,
PRIMARY KEY (theme_id, path))" PRIMARY KEY (theme_id, path))"
// Web log tables // Web log table
if needsTable "web_log" then if needsTable Table.WebLog then
"CREATE TABLE web_log ( $"CREATE TABLE {Table.WebLog} (data TEXT NOT NULL);
id TEXT PRIMARY KEY, CREATE UNIQUE INDEX idx_{Table.WebLog}_key ON {Table.WebLog} (data ->> 'Id')"
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)"
// Category table // Category table
if needsTable "category" then if needsTable Table.Category then
"CREATE TABLE category ( $"CREATE TABLE {Table.Category} (data TEXT NOT NULL);
id TEXT PRIMARY KEY, CREATE UNIQUE INDEX idx_{Table.Category}_key ON {Table.Category} (data -> 'Id');
web_log_id TEXT NOT NULL REFERENCES web_log (id), CREATE INDEX idx_{Table.Category}_web_log ON {Table.Category} (data ->> 'WebLogId')"
name TEXT NOT NULL,
slug TEXT NOT NULL,
description TEXT,
parent_id TEXT);
CREATE INDEX category_web_log_idx ON category (web_log_id)"
// Web log user table // Web log user table
if needsTable "web_log_user" then if needsTable Table.WebLogUser then
"CREATE TABLE web_log_user ( $"CREATE TABLE web_log_user (data TEXT NOT NULL);
id TEXT PRIMARY KEY, CREATE UNIQUE INDEX idx_{Table.WebLogUser}_key ON {Table.WebLogUser} (data ->> 'Id');
web_log_id TEXT NOT NULL REFERENCES web_log (id), CREATE INDEX idx_{Table.WebLogUser}_email ON {Table.WebLogUser} (data ->> 'WebLogId', data ->> 'Email')"
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)"
// Page tables // Page tables
if needsTable "page" then if needsTable Table.Page then
"CREATE TABLE page ( $"CREATE TABLE {Table.Page} (data TEXT NOT NULL);
id TEXT PRIMARY KEY, CREATE UNIQUE INDEX idx_{Table.Page}_key ON {Table.Page} (data ->> 'Id');
web_log_id TEXT NOT NULL REFERENCES web_log (id), CREATE INDEX idx_{Table.Page}_author ON {Table.Page} (data ->> 'AuthorId');
author_id TEXT NOT NULL REFERENCES web_log_user (id), CREATE INDEX idx_{Table.Page}_permalink ON {Table.Page} (data ->> 'WebLogId', data ->> 'Permalink')"
title TEXT NOT NULL, if needsTable Table.PageRevision then
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
"CREATE TABLE page_revision ( "CREATE TABLE page_revision (
page_id TEXT NOT NULL REFERENCES page (id), page_id TEXT NOT NULL,
as_of TEXT NOT NULL, as_of TEXT NOT NULL,
revision_text TEXT NOT NULL, revision_text TEXT NOT NULL,
PRIMARY KEY (page_id, as_of))" PRIMARY KEY (page_id, as_of))"
// Post tables // Post tables
if needsTable "post" then if needsTable Table.Post then
"CREATE TABLE post ( $"CREATE TABLE {Table.Post} (data TEXT NOT NULL);
id TEXT PRIMARY KEY, CREATE UNIQUE INDEX idx_{Table.Post}_key ON {Table.Post} (data ->> 'Id');
web_log_id TEXT NOT NULL REFERENCES web_log (id), CREATE INDEX idx_{Table.Post}_author ON {Table.Post} (data ->> 'AuthorId');
author_id TEXT NOT NULL REFERENCES web_log_user (id), CREATE INDEX idx_{Table.Post}_status ON {Table.Post} (data ->> 'WebLogId', data ->> 'Status', data ->> 'UpdatedOn');
status TEXT NOT NULL, CREATE INDEX idx_{Table.Post}_permalink ON {Table.Post} (data ->> 'WebLogId', data ->> 'Permalink')"
title TEXT NOT NULL, // TODO: index categories by post?
permalink TEXT NOT NULL, if needsTable Table.PostRevision then
published_on TEXT, $"CREATE TABLE {Table.PostRevision} (
updated_on TEXT NOT NULL, post_id 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),
as_of TEXT NOT NULL, as_of TEXT NOT NULL,
revision_text TEXT NOT NULL, revision_text TEXT NOT NULL,
PRIMARY KEY (post_id, as_of))" PRIMARY KEY (post_id, as_of))"
if needsTable "post_comment" then if needsTable Table.PostComment then
"CREATE TABLE post_comment ( $"CREATE TABLE {Table.PostComment} (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
post_id TEXT NOT NULL REFERENCES post(id), post_id TEXT NOT NULL,
in_reply_to_id TEXT, in_reply_to_id TEXT,
name TEXT NOT NULL, name TEXT NOT NULL,
email TEXT NOT NULL, email TEXT NOT NULL,
@ -186,32 +93,28 @@ type SQLiteData (conn : SqliteConnection, log : ILogger<SQLiteData>, ser : JsonS
status TEXT NOT NULL, status TEXT NOT NULL,
posted_on TEXT NOT NULL, posted_on TEXT NOT NULL,
comment_text 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 // Tag map table
if needsTable "tag_map" then if needsTable Table.TagMap then
"CREATE TABLE tag_map ( $"CREATE TABLE {Table.TagMap} (data TEXT NOT NULL);
id TEXT PRIMARY KEY, CREATE UNIQUE INDEX idx_{Table.TagMap}_key ON {Table.TagMap} (data ->> 'Id');
web_log_id TEXT NOT NULL REFERENCES web_log (id), CREATE INDEX idx_{Table.TagMap}_tag ON {Table.TagMap} (data ->> 'WebLogId', data ->> 'UrlValue')";
tag TEXT NOT NULL,
url_value TEXT NOT NULL);
CREATE INDEX tag_map_web_log_idx ON tag_map (web_log_id)"
// Uploaded file table // Uploaded file table
if needsTable "upload" then if needsTable Table.Upload then
"CREATE TABLE upload ( $"CREATE TABLE {Table.Upload} (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
web_log_id TEXT NOT NULL REFERENCES web_log (id), web_log_id TEXT NOT NULL,
path TEXT NOT NULL, path TEXT NOT NULL,
updated_on TEXT NOT NULL, updated_on TEXT NOT NULL,
data BLOB NOT NULL); data BLOB NOT NULL);
CREATE INDEX upload_web_log_idx ON upload (web_log_id); CREATE INDEX idx_{Table.Upload}_path ON {Table.Upload} (web_log_id, path)"
CREATE INDEX upload_path_idx ON upload (web_log_id, path)"
// Database version table // Database version table
if needsTable "db_version" then if needsTable Table.DbVersion then
"CREATE TABLE db_version (id TEXT PRIMARY KEY); $"CREATE TABLE {Table.DbVersion} (id TEXT PRIMARY KEY);
INSERT INTO db_version VALUES ('v2')" INSERT INTO {Table.DbVersion} VALUES ('v2.1')"
} }
|> Seq.map (fun sql -> |> Seq.map (fun sql ->
log.LogInformation $"Creating {(sql.Split ' ')[2]} table..." log.LogInformation $"Creating {(sql.Split ' ')[2]} table..."
@ -224,7 +127,7 @@ type SQLiteData (conn : SqliteConnection, log : ILogger<SQLiteData>, ser : JsonS
/// Set the database version to the specified version /// Set the database version to the specified version
let setDbVersion version = backgroundTask { let setDbVersion version = backgroundTask {
use cmd = conn.CreateCommand () 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 do! write cmd
} }
@ -600,7 +503,7 @@ type SQLiteData (conn : SqliteConnection, log : ILogger<SQLiteData>, ser : JsonS
do! ensureTables () do! ensureTables ()
use cmd = conn.CreateCommand () use cmd = conn.CreateCommand ()
cmd.CommandText <- "SELECT id FROM db_version" cmd.CommandText <- $"SELECT id FROM {Table.DbVersion}"
use! rdr = cmd.ExecuteReaderAsync () use! rdr = cmd.ExecuteReaderAsync ()
do! migrate (if rdr.Read () then Some (Map.getString "id" rdr) else None) do! migrate (if rdr.Read () then Some (Map.getString "id" rdr) else None)
} }

View File

@ -6,405 +6,404 @@ open NodaTime
/// A category under which a post may be identified /// A category under which a post may be identified
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type Category = type Category = {
{ /// The ID of the category /// The ID of the category
Id : CategoryId 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 WebLogId : WebLogId
/// The displayed name /// The displayed name
Name : string Name : string
/// The slug (used in category URLs) /// The slug (used in category URLs)
Slug : string Slug : string
/// A longer description of the category /// A longer description of the category
Description : string option Description : string option
/// The parent ID of this category (if a subcategory) /// The parent ID of this category (if a subcategory)
ParentId : CategoryId option ParentId : CategoryId option
} }
/// Functions to support categories /// Functions to support categories
module Category = module Category =
/// An empty category /// An empty category
let empty = let empty = {
{ Id = CategoryId.empty Id = CategoryId.empty
WebLogId = WebLogId.empty WebLogId = WebLogId.empty
Name = "" Name = ""
Slug = "" Slug = ""
Description = None Description = None
ParentId = None ParentId = None
} }
/// A comment on a post /// A comment on a post
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type Comment = type Comment = {
{ /// The ID of the comment /// The ID of the comment
Id : CommentId Id : CommentId
/// The ID of the post to which this comment applies /// The ID of the post to which this comment applies
PostId : PostId 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 InReplyToId : CommentId option
/// The name of the commentor /// The name of the commentor
Name : string Name : string
/// The e-mail address of the commentor /// The e-mail address of the commentor
Email : string Email : string
/// The URL of the commentor's personal website /// The URL of the commentor's personal website
Url : string option Url : string option
/// The status of the comment /// The status of the comment
Status : CommentStatus Status : CommentStatus
/// When the comment was posted /// When the comment was posted
PostedOn : Instant PostedOn : Instant
/// The text of the comment /// The text of the comment
Text : string Text : string
} }
/// Functions to support comments /// Functions to support comments
module Comment = module Comment =
/// An empty comment /// An empty comment
let empty = let empty = {
{ Id = CommentId.empty Id = CommentId.empty
PostId = PostId.empty PostId = PostId.empty
InReplyToId = None InReplyToId = None
Name = "" Name = ""
Email = "" Email = ""
Url = None Url = None
Status = Pending Status = Pending
PostedOn = Noda.epoch PostedOn = Noda.epoch
Text = "" Text = ""
} }
/// A page (text not associated with a date/time) /// A page (text not associated with a date/time)
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type Page = type Page = {
{ /// The ID of this page /// The ID of this page
Id : PageId 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 WebLogId : WebLogId
/// The ID of the author of this page /// The ID of the author of this page
AuthorId : WebLogUserId AuthorId : WebLogUserId
/// The title of the page /// The title of the page
Title : string Title : string
/// The link at which this page is displayed /// The link at which this page is displayed
Permalink : Permalink Permalink : Permalink
/// When this page was published /// When this page was published
PublishedOn : Instant PublishedOn : Instant
/// When this page was last updated /// When this page was last updated
UpdatedOn : Instant 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 IsInPageList : bool
/// The template to use when rendering this page /// The template to use when rendering this page
Template : string option Template : string option
/// The current text of the page /// The current text of the page
Text : string Text : string
/// Metadata for this page /// Metadata for this page
Metadata : MetaItem list 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 PriorPermalinks : Permalink list
/// Revisions of this page /// Revisions of this page
Revisions : Revision list Revisions : Revision list
} }
/// Functions to support pages /// Functions to support pages
module Page = module Page =
/// An empty page /// An empty page
let empty = let empty = {
{ Id = PageId.empty Id = PageId.empty
WebLogId = WebLogId.empty WebLogId = WebLogId.empty
AuthorId = WebLogUserId.empty AuthorId = WebLogUserId.empty
Title = "" Title = ""
Permalink = Permalink.empty Permalink = Permalink.empty
PublishedOn = Noda.epoch PublishedOn = Noda.epoch
UpdatedOn = Noda.epoch UpdatedOn = Noda.epoch
IsInPageList = false IsInPageList = false
Template = None Template = None
Text = "" Text = ""
Metadata = [] Metadata = []
PriorPermalinks = [] PriorPermalinks = []
Revisions = [] Revisions = []
} }
/// A web log post /// A web log post
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type Post = type Post = {
{ /// The ID of this post /// The ID of this post
Id : PostId 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 WebLogId : WebLogId
/// The ID of the author of this post /// The ID of the author of this post
AuthorId : WebLogUserId AuthorId : WebLogUserId
/// The status /// The status
Status : PostStatus Status : PostStatus
/// The title /// The title
Title : string Title : string
/// The link at which the post resides /// The link at which the post resides
Permalink : Permalink Permalink : Permalink
/// The instant on which the post was originally published /// The instant on which the post was originally published
PublishedOn : Instant option PublishedOn : Instant option
/// The instant on which the post was last updated /// The instant on which the post was last updated
UpdatedOn : Instant UpdatedOn : Instant
/// The template to use in displaying the post /// The template to use in displaying the post
Template : string option 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 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 CategoryIds : CategoryId list
/// The tags for the post /// The tags for the post
Tags : string list Tags : string list
/// Podcast episode information for this post /// Podcast episode information for this post
Episode : Episode option Episode : Episode option
/// Metadata for the post /// Metadata for the post
Metadata : MetaItem list 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 PriorPermalinks : Permalink list
/// The revisions for this post /// The revisions for this post
Revisions : Revision list Revisions : Revision list
} }
/// Functions to support posts /// Functions to support posts
module Post = module Post =
/// An empty post /// An empty post
let empty = let empty = {
{ Id = PostId.empty Id = PostId.empty
WebLogId = WebLogId.empty WebLogId = WebLogId.empty
AuthorId = WebLogUserId.empty AuthorId = WebLogUserId.empty
Status = Draft Status = Draft
Title = "" Title = ""
Permalink = Permalink.empty Permalink = Permalink.empty
PublishedOn = None PublishedOn = None
UpdatedOn = Noda.epoch UpdatedOn = Noda.epoch
Text = "" Text = ""
Template = None Template = None
CategoryIds = [] CategoryIds = []
Tags = [] Tags = []
Episode = None Episode = None
Metadata = [] Metadata = []
PriorPermalinks = [] PriorPermalinks = []
Revisions = [] Revisions = []
} }
/// A mapping between a tag and its URL value, used to translate restricted characters (ex. "#1" -> "number-1") /// A mapping between a tag and its URL value, used to translate restricted characters (ex. "#1" -> "number-1")
type TagMap = type TagMap = {
{ /// The ID of this tag mapping /// The ID of this tag mapping
Id : TagMapId 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 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 Tag : string
/// The value by which the tag should be linked /// The value by which the tag should be linked
UrlValue : string UrlValue : string
} }
/// Functions to support tag mappings /// Functions to support tag mappings
module TagMap = module TagMap =
/// An empty tag mapping /// An empty tag mapping
let empty = let empty = {
{ Id = TagMapId.empty Id = TagMapId.empty
WebLogId = WebLogId.empty WebLogId = WebLogId.empty
Tag = "" Tag = ""
UrlValue = "" UrlValue = ""
} }
/// A theme /// A theme
type Theme = type Theme = {
{ /// The ID / path of the theme /// The ID / path of the theme
Id : ThemeId Id : ThemeId
/// A long name of the theme /// A long name of the theme
Name : string Name : string
/// The version of the theme /// The version of the theme
Version : string Version : string
/// The templates for this theme /// The templates for this theme
Templates: ThemeTemplate list Templates: ThemeTemplate list
} }
/// Functions to support themes /// Functions to support themes
module Theme = module Theme =
/// An empty theme /// An empty theme
let empty = let empty = {
{ Id = ThemeId "" Id = ThemeId ""
Name = "" Name = ""
Version = "" Version = ""
Templates = [] 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 = type ThemeAsset = {
{ /// The ID of the asset (consists of theme and path)
/// The ID of the asset (consists of theme and path) Id : ThemeAssetId
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 UpdatedOn : Instant
/// The data for the asset /// The data for the asset
Data : byte[] Data : byte[]
} }
/// Functions to support theme assets /// Functions to support theme assets
module ThemeAsset = module ThemeAsset =
/// An empty theme asset /// An empty theme asset
let empty = let empty = {
{ Id = ThemeAssetId (ThemeId "", "") Id = ThemeAssetId (ThemeId "", "")
UpdatedOn = Noda.epoch UpdatedOn = Noda.epoch
Data = [||] Data = [||]
} }
/// An uploaded file /// An uploaded file
type Upload = type Upload = {
{ /// The ID of the upload /// The ID of the upload
Id : UploadId 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 WebLogId : WebLogId
/// The link at which this upload is served /// The link at which this upload is served
Path : Permalink Path : Permalink
/// The updated date/time for this upload /// The updated date/time for this upload
UpdatedOn : Instant UpdatedOn : Instant
/// The data for the upload /// The data for the upload
Data : byte[] Data : byte[]
} }
/// Functions to support uploaded files /// Functions to support uploaded files
module Upload = module Upload =
/// An empty upload /// An empty upload
let empty = let empty = {
{ Id = UploadId.empty Id = UploadId.empty
WebLogId = WebLogId.empty WebLogId = WebLogId.empty
Path = Permalink.empty Path = Permalink.empty
UpdatedOn = Noda.epoch UpdatedOn = Noda.epoch
Data = [||] Data = [||]
} }
/// A web log /// A web log
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type WebLog = type WebLog = {
{ /// The ID of the web log /// The ID of the web log
Id : WebLogId Id : WebLogId
/// The name of the web log /// The name of the web log
Name : string Name : string
/// The slug of the web log /// The slug of the web log
Slug : string Slug : string
/// A subtitle for the web log /// A subtitle for the web log
Subtitle : string option Subtitle : string option
/// The default page ("posts" or a page Id) /// The default page ("posts" or a page Id)
DefaultPage : string DefaultPage : string
/// The number of posts to display on pages of posts /// The number of posts to display on pages of posts
PostsPerPage : int PostsPerPage : int
/// The ID of the theme (also the path within /themes) /// The ID of the theme (also the path within /themes)
ThemeId : ThemeId ThemeId : ThemeId
/// The URL base /// The URL base
UrlBase : string UrlBase : string
/// The time zone in which dates/times should be displayed /// The time zone in which dates/times should be displayed
TimeZone : string TimeZone : string
/// The RSS options for this web log /// The RSS options for this web log
Rss : RssOptions Rss : RssOptions
/// Whether to automatically load htmx /// Whether to automatically load htmx
AutoHtmx : bool AutoHtmx : bool
/// Where uploads are placed /// Where uploads are placed
Uploads : UploadDestination Uploads : UploadDestination
/// Redirect rules for this weblog /// Redirect rules for this weblog
RedirectRules : RedirectRule list RedirectRules : RedirectRule list
} }
/// Functions to support web logs /// Functions to support web logs
module WebLog = module WebLog =
/// An empty web log /// An empty web log
let empty = let empty = {
{ Id = WebLogId.empty Id = WebLogId.empty
Name = "" Name = ""
Slug = "" Slug = ""
Subtitle = None Subtitle = None
DefaultPage = "" DefaultPage = ""
PostsPerPage = 10 PostsPerPage = 10
ThemeId = ThemeId "default" ThemeId = ThemeId "default"
UrlBase = "" UrlBase = ""
TimeZone = "" TimeZone = ""
Rss = RssOptions.empty Rss = RssOptions.empty
AutoHtmx = false AutoHtmx = false
Uploads = Database Uploads = Database
RedirectRules = [] RedirectRules = []
} }
/// Get the host (including scheme) and extra path from the URL base /// Get the host (including scheme) and extra path from the URL base
let hostAndPath webLog = let hostAndPath webLog =
let scheme = webLog.UrlBase.Split "://" let scheme = webLog.UrlBase.Split "://"
let host = scheme[1].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 /// Generate an absolute URL for the given link
let absoluteUrl webLog permalink = let absoluteUrl webLog permalink =
@ -418,71 +417,71 @@ module WebLog =
/// 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
let localTime webLog (date : Instant) = let localTime webLog (date : Instant) =
match DateTimeZoneProviders.Tzdb[webLog.TimeZone] with match DateTimeZoneProviders.Tzdb[webLog.TimeZone] with
| null -> date.ToDateTimeUtc () | null -> date.ToDateTimeUtc()
| tz -> date.InZone(tz).ToDateTimeUnspecified () | tz -> date.InZone(tz).ToDateTimeUnspecified()
/// A user of the web log /// A user of the web log
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type WebLogUser = type WebLogUser = {
{ /// The ID of the user /// The ID of the user
Id : WebLogUserId 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 WebLogId : WebLogId
/// The user name (e-mail address) /// The user name (e-mail address)
Email : string Email : string
/// The user's first name /// The user's first name
FirstName : string FirstName : string
/// The user's last name /// The user's last name
LastName : string LastName : string
/// The user's preferred name /// The user's preferred name
PreferredName : string PreferredName : string
/// The hash of the user's password /// The hash of the user's password
PasswordHash : string PasswordHash : string
/// The URL of the user's personal site /// The URL of the user's personal site
Url : string option Url : string option
/// The user's access level /// The user's access level
AccessLevel : AccessLevel AccessLevel : AccessLevel
/// When the user was created /// When the user was created
CreatedOn : Instant CreatedOn : Instant
/// When the user last logged on /// When the user last logged on
LastSeenOn : Instant option LastSeenOn : Instant option
} }
/// Functions to support web log users /// Functions to support web log users
module WebLogUser = module WebLogUser =
/// An empty web log user /// An empty web log user
let empty = let empty = {
{ Id = WebLogUserId.empty Id = WebLogUserId.empty
WebLogId = WebLogId.empty WebLogId = WebLogId.empty
Email = "" Email = ""
FirstName = "" FirstName = ""
LastName = "" LastName = ""
PreferredName = "" PreferredName = ""
PasswordHash = "" PasswordHash = ""
Url = None Url = None
AccessLevel = Author AccessLevel = Author
CreatedOn = Noda.epoch CreatedOn = Noda.epoch
LastSeenOn = None LastSeenOn = None
} }
/// Get the user's displayed name /// Get the user's displayed name
let displayName user = let displayName user =
let name = let name =
seq { match user.PreferredName with "" -> user.FirstName | n -> n; " "; user.LastName } seq { match user.PreferredName with "" -> user.FirstName | n -> n; " "; user.LastName }
|> Seq.reduce (+) |> Seq.reduce (+)
name.Trim () name.Trim()
/// Does a user have the required access level? /// Does a user have the required access level?
let hasAccess level user = let hasAccess level user =

View File

@ -10,7 +10,7 @@ module private Helpers =
/// Create a new ID (short GUID) /// Create a new ID (short GUID)
// https://www.madskristensen.net/blog/A-shorter-and-URL-friendly-GUID // https://www.madskristensen.net/blog/A-shorter-and-URL-friendly-GUID
let newId () = 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 /// Functions to support NodaTime manipulation
@ -22,18 +22,17 @@ module Noda =
/// The Unix epoch /// The Unix epoch
let epoch = Instant.FromUnixTimeSeconds 0L let epoch = Instant.FromUnixTimeSeconds 0L
/// Truncate an instant to remove fractional seconds /// Truncate an instant to remove fractional seconds
let toSecondsPrecision (value : Instant) = let toSecondsPrecision (value : Instant) =
Instant.FromUnixTimeSeconds (value.ToUnixTimeSeconds ()) Instant.FromUnixTimeSeconds(value.ToUnixTimeSeconds())
/// The current Instant, with fractional seconds truncated /// The current Instant, with fractional seconds truncated
let now () = let now =
toSecondsPrecision (clock.GetCurrentInstant ()) clock.GetCurrentInstant >> toSecondsPrecision
/// Convert a date/time to an Instant with whole seconds /// Convert a date/time to an Instant with whole seconds
let fromDateTime (dt : DateTime) = 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 /// A user's access level
@ -94,7 +93,7 @@ module CategoryId =
let toString = function CategoryId ci -> ci let toString = function CategoryId ci -> ci
/// Create a new category ID /// Create a new category ID
let create () = CategoryId (newId ()) let create = newId >> CategoryId
/// An identifier for a comment /// An identifier for a comment
@ -110,7 +109,7 @@ module CommentId =
let toString = function CommentId ci -> ci let toString = function CommentId ci -> ci
/// Create a new comment ID /// Create a new comment ID
let create () = CommentId (newId ()) let create = newId >> CommentId
/// Statuses for post comments /// Statuses for post comments
@ -134,7 +133,7 @@ module CommentStatus =
| "Approved" -> Approved | "Approved" -> Approved
| "Pending" -> Pending | "Pending" -> Pending
| "Spam" -> Spam | "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 /// Valid values for the iTunes explicit rating
@ -158,127 +157,127 @@ module ExplicitRating =
| "yes" -> Yes | "yes" -> Yes
| "no" -> No | "no" -> No
| "clean" -> Clean | "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) /// A location (specified by Podcast Index)
type Location = type Location = {
{ /// The name of the location (free-form text) /// The name of the location (free-form text)
Name : string Name : string
/// A geographic coordinate string (RFC 5870) /// A geographic coordinate string (RFC 5870)
Geo : string option Geo : string option
/// An OpenStreetMap query /// An OpenStreetMap query
Osm : string option Osm : string option
} }
/// A chapter in a podcast episode /// A chapter in a podcast episode
type Chapter = type Chapter = {
{ /// The start time for the chapter /// The start time for the chapter
StartTime : Duration StartTime : Duration
/// The title for this chapter /// The title for this chapter
Title : string option Title : string option
/// A URL for an image for this chapter /// A URL for an image for this chapter
ImageUrl : string option ImageUrl : string option
/// Whether this chapter is hidden /// Whether this chapter is hidden
IsHidden : bool option IsHidden : bool option
/// The episode end time for the chapter /// The episode end time for the chapter
EndTime : Duration option EndTime : Duration option
/// A location that applies to a chapter /// A location that applies to a chapter
Location : Location option Location : Location option
} }
open NodaTime.Text open NodaTime.Text
/// A podcast episode /// A podcast episode
type 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 Media : string
/// The length of the media file, in bytes /// The length of the media file, in bytes
Length : int64 Length : int64
/// The duration of the episode /// The duration of the episode
Duration : Duration option 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 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 ImageUrl : string option
/// A subtitle for this episode /// A subtitle for this episode
Subtitle : string option 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 Explicit : ExplicitRating option
/// Chapters for this episode /// Chapters for this episode
Chapters : Chapter list option Chapters : Chapter list option
/// A link to a chapter file /// A link to a chapter file
ChapterFile : string option ChapterFile : string option
/// The MIME type for the chapter file /// The MIME type for the chapter file
ChapterType : string option ChapterType : string 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 TranscriptUrl : string option
/// The MIME type of the transcript /// The MIME type of the transcript
TranscriptType : string option TranscriptType : string option
/// The language in which the transcript is written /// The language in which the transcript is written
TranscriptLang : string option 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 TranscriptCaptions : bool option
/// The season number (for serialized podcasts) /// The season number (for serialized podcasts)
SeasonNumber : int option SeasonNumber : int option
/// A description of the season /// A description of the season
SeasonDescription : string option SeasonDescription : string option
/// The episode number /// The episode number
EpisodeNumber : double option EpisodeNumber : double option
/// A description of the episode /// A description of the episode
EpisodeDescription : string option EpisodeDescription : string option
} }
/// Functions to support episodes /// Functions to support episodes
module Episode = module Episode =
/// An empty episode /// An empty episode
let empty = let empty = {
{ Media = "" Media = ""
Length = 0L Length = 0L
Duration = None Duration = None
MediaType = None MediaType = None
ImageUrl = None ImageUrl = None
Subtitle = None Subtitle = None
Explicit = None Explicit = None
Chapters = None Chapters = None
ChapterFile = None ChapterFile = None
ChapterType = None ChapterType = None
TranscriptUrl = None TranscriptUrl = None
TranscriptType = None TranscriptType = None
TranscriptLang = None TranscriptLang = None
TranscriptCaptions = None TranscriptCaptions = None
SeasonNumber = None SeasonNumber = None
SeasonDescription = None SeasonDescription = None
EpisodeNumber = None EpisodeNumber = None
EpisodeDescription = None EpisodeDescription = None
} }
/// Format a duration for an episode /// Format a duration for an episode
let formatDuration ep = let formatDuration ep =
@ -299,7 +298,7 @@ type MarkupText =
module MarkupText = module MarkupText =
/// Pipeline with most extensions enabled /// 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 /// Get the source type for the markup text
let sourceType = function Markdown _ -> "Markdown" | Html _ -> "HTML" let sourceType = function Markdown _ -> "Markdown" | Html _ -> "HTML"
@ -311,25 +310,25 @@ module MarkupText =
let toString it = $"{sourceType it}: {text it}" let toString it = $"{sourceType it}: {text it}"
/// Get the HTML representation of the markup text /// 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 /// Parse a string into a MarkupText instance
let parse (it : string) = let parse (it : string) =
match it with match it with
| text when text.StartsWith "Markdown: " -> Markdown (text.Substring 10) | text when text.StartsWith "Markdown: " -> Markdown text[10..]
| text when text.StartsWith "HTML: " -> Html (text.Substring 6) | text when text.StartsWith "HTML: " -> Html text[6..]
| text -> invalidOp $"Cannot derive type of text ({text})" | text -> invalidOp $"Cannot derive type of text ({text})"
/// An item of metadata /// An item of metadata
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type MetaItem = type MetaItem = {
{ /// The name of the metadata value /// The name of the metadata value
Name : string Name : string
/// The metadata value /// The metadata value
Value : string Value : string
} }
/// Functions to support metadata items /// Functions to support metadata items
module MetaItem = module MetaItem =
@ -340,22 +339,20 @@ module MetaItem =
/// A revision of a page or post /// A revision of a page or post
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type Revision = type Revision = {
{ /// When this revision was saved /// When this revision was saved
AsOf : Instant AsOf : Instant
/// The text of the revision /// The text of the revision
Text : MarkupText Text : MarkupText
} }
/// Functions to support revisions /// Functions to support revisions
module Revision = module Revision =
/// An empty revision /// An empty revision
let empty = let empty =
{ AsOf = Noda.epoch { AsOf = Noda.epoch; Text = Html "" }
Text = Html ""
}
/// A permanent link /// A permanent link
@ -384,7 +381,7 @@ module PageId =
let toString = function PageId pi -> pi let toString = function PageId pi -> pi
/// Create a new page ID /// Create a new page ID
let create () = PageId (newId ()) let create = newId >> PageId
/// PodcastIndex.org podcast:medium allowed values /// PodcastIndex.org podcast:medium allowed values
@ -421,7 +418,7 @@ module PodcastMedium =
| "audiobook" -> Audiobook | "audiobook" -> Audiobook
| "newsletter" -> Newsletter | "newsletter" -> Newsletter
| "blog" -> Blog | "blog" -> Blog
| it -> invalidOp $"{it} is not a valid podcast medium" | it -> invalidArg "medium" $"{it} is not a valid podcast medium"
/// Statuses for posts /// Statuses for posts
@ -442,7 +439,7 @@ module PostStatus =
match value with match value with
| "Draft" -> Draft | "Draft" -> Draft
| "Published" -> Published | "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 /// An identifier for a post
@ -458,30 +455,30 @@ module PostId =
let toString = function PostId pi -> pi let toString = function PostId pi -> pi
/// Create a new post ID /// Create a new post ID
let create () = PostId (newId ()) let create = newId >> PostId
/// A redirection for a previously valid URL /// A redirection for a previously valid URL
type RedirectRule = type RedirectRule = {
{ /// The From string or pattern /// The From string or pattern
From : string From : string
/// The To string or pattern /// The To string or pattern
To : string To : string
/// Whether to use regular expressions on this rule /// Whether to use regular expressions on this rule
IsRegex : bool IsRegex : bool
} }
/// Functions to support redirect rules /// Functions to support redirect rules
module RedirectRule = module RedirectRule =
/// An empty redirect rule /// An empty redirect rule
let empty = let empty = {
{ From = "" From = ""
To = "" To = ""
IsRegex = false IsRegex = false
} }
/// An identifier for a custom feed /// An identifier for a custom feed
@ -497,7 +494,7 @@ module CustomFeedId =
let toString = function CustomFeedId pi -> pi let toString = function CustomFeedId pi -> pi
/// Create a new custom feed ID /// Create a new custom feed ID
let create () = CustomFeedId (newId ()) let create = newId >> CustomFeedId
/// The source for a custom feed /// The source for a custom feed
@ -525,122 +522,122 @@ module CustomFeedSource =
/// Options for a feed that describes a podcast /// Options for a feed that describes a podcast
type PodcastOptions = type PodcastOptions = {
{ /// The title of the podcast /// The title of the podcast
Title : string Title : string
/// A subtitle for the podcast /// A subtitle for the podcast
Subtitle : string option Subtitle : string option
/// The number of items in the podcast feed /// The number of items in the podcast feed
ItemsInFeed : int ItemsInFeed : int
/// A summary of the podcast (iTunes field) /// A summary of the podcast (iTunes field)
Summary : string Summary : string
/// The display name of the podcast author (iTunes field) /// The display name of the podcast author (iTunes field)
DisplayedAuthor : string 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 Email : string
/// The link to the image for the podcast /// The link to the image for the podcast
ImageUrl : Permalink 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 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 option AppleSubcategory : string option
/// The explictness rating (iTunes field) /// The explictness rating (iTunes field)
Explicit : ExplicitRating Explicit : ExplicitRating
/// The default media type for files in this podcast /// The default media type for files in this podcast
DefaultMediaType : string option DefaultMediaType : string option
/// 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 option MediaBaseUrl : string option
/// A GUID for this podcast /// A GUID for this podcast
PodcastGuid : Guid option 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 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 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 Medium : PodcastMedium option
} }
/// A custom feed /// A custom feed
type CustomFeed = type CustomFeed = {
{ /// The ID of the custom feed /// The ID of the custom feed
Id : CustomFeedId Id : CustomFeedId
/// The source for the custom feed /// The source for the custom feed
Source : CustomFeedSource Source : CustomFeedSource
/// The path for the custom feed /// The path for the custom feed
Path : Permalink Path : Permalink
/// Podcast options, if the feed defines a podcast /// Podcast options, if the feed defines a podcast
Podcast : PodcastOptions option Podcast : PodcastOptions option
} }
/// Functions to support custom feeds /// Functions to support custom feeds
module CustomFeed = module CustomFeed =
/// An empty custom feed /// An empty custom feed
let empty = let empty = {
{ Id = CustomFeedId "" Id = CustomFeedId ""
Source = Category (CategoryId "") Source = Category (CategoryId "")
Path = Permalink "" Path = Permalink ""
Podcast = None Podcast = None
} }
/// Really Simple Syndication (RSS) options for this web log /// Really Simple Syndication (RSS) options for this web log
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type RssOptions = type RssOptions = {
{ /// Whether the site feed of posts is enabled /// Whether the site feed of posts is enabled
IsFeedEnabled : bool IsFeedEnabled : bool
/// The name of the file generated for the site feed /// The name of the file generated for the site feed
FeedName : string 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 ItemsInFeed : int option
/// Whether feeds are enabled for all categories /// Whether feeds are enabled for all categories
IsCategoryEnabled : bool IsCategoryEnabled : bool
/// Whether feeds are enabled for all tags /// Whether feeds are enabled for all tags
IsTagEnabled : bool IsTagEnabled : bool
/// A copyright string to be placed in all feeds /// A copyright string to be placed in all feeds
Copyright : string option Copyright : string option
/// Custom feeds for this web log /// Custom feeds for this web log
CustomFeeds: CustomFeed list CustomFeeds: CustomFeed list
} }
/// Functions to support RSS options /// Functions to support RSS options
module RssOptions = module RssOptions =
/// An empty set of RSS options /// An empty set of RSS options
let empty = let empty = {
{ IsFeedEnabled = true IsFeedEnabled = true
FeedName = "feed.xml" FeedName = "feed.xml"
ItemsInFeed = None ItemsInFeed = None
IsCategoryEnabled = true IsCategoryEnabled = true
IsTagEnabled = true IsTagEnabled = true
Copyright = None Copyright = None
CustomFeeds = [] CustomFeeds = []
} }
/// An identifier for a tag mapping /// An identifier for a tag mapping
@ -656,7 +653,7 @@ module TagMapId =
let toString = function TagMapId tmi -> tmi let toString = function TagMapId tmi -> tmi
/// Create a new tag mapping ID /// Create a new tag mapping ID
let create () = TagMapId (newId ()) let create = newId >> TagMapId
/// An identifier for a theme (represents its path) /// An identifier for a theme (represents its path)
@ -683,22 +680,20 @@ module ThemeAssetId =
/// A template for a theme /// A template for a theme
type ThemeTemplate = type ThemeTemplate = {
{ /// The name of the template /// The name of the template
Name : string Name : string
/// The text of the template /// The text of the template
Text : string Text : string
} }
/// Functions to support theme templates /// Functions to support theme templates
module ThemeTemplate = module ThemeTemplate =
/// An empty theme template /// An empty theme template
let empty = let empty =
{ Name = "" { Name = ""; Text = "" }
Text = ""
}
/// Where uploads should be placed /// Where uploads should be placed
@ -717,7 +712,7 @@ module UploadDestination =
match value with match value with
| "Database" -> Database | "Database" -> Database
| "Disk" -> Disk | "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 /// An identifier for an upload
@ -733,7 +728,7 @@ module UploadId =
let toString = function UploadId ui -> ui let toString = function UploadId ui -> ui
/// Create a new upload ID /// Create a new upload ID
let create () = UploadId (newId ()) let create = newId >> UploadId
/// An identifier for a web log /// An identifier for a web log
@ -749,7 +744,7 @@ module WebLogId =
let toString = function WebLogId wli -> wli let toString = function WebLogId wli -> wli
/// Create a new web log ID /// 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 let toString = function WebLogUserId wli -> wli
/// Create a new web log user ID /// Create a new web log user ID
let create () = WebLogUserId (newId ()) let create = newId >> WebLogUserId

View File

@ -10,7 +10,7 @@ module private Helpers =
/// Create a string option if a string is blank /// Create a string option if a string is blank
let noneIfBlank (it : string) = 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 /// Helper functions that are needed outside this file
@ -26,67 +26,70 @@ module PublicHelpers =
/// The model used to display the admin dashboard /// The model used to display the admin dashboard
[<NoComparison; NoEquality>] [<NoComparison; NoEquality>]
type DashboardModel = type DashboardModel = {
{ /// The number of published posts /// The number of published posts
Posts : int Posts : int
/// The number of post drafts /// The number of post drafts
Drafts : int Drafts : int
/// The number of pages /// The number of pages
Pages : int Pages : int
/// The number of pages in the page list /// The number of pages in the page list
ListedPages : int ListedPages : int
/// The number of categories /// The number of categories
Categories : int Categories : int
/// The top-level categories /// The top-level categories
TopLevelCategories : int TopLevelCategories : int
} }
/// Details about a category, used to display category lists /// Details about a category, used to display category lists
[<NoComparison; NoEquality>] [<NoComparison; NoEquality>]
type DisplayCategory = type DisplayCategory = {
{ /// The ID of the category /// The ID of the category
Id : string Id : string
/// The slug for the category /// The slug for the category
Slug : string Slug : string
/// The name of the category /// The name of the category
Name : string Name : string
/// A description of the category /// A description of the category
Description : string option Description : string option
/// The parent category names for this (sub)category /// The parent category names for this (sub)category
ParentNames : string[] ParentNames : string[]
/// The number of posts in this category /// The number of posts in this category
PostCount : int PostCount : int
} }
/// A display version of a custom feed definition /// A display version of a custom feed definition
type DisplayCustomFeed = type DisplayCustomFeed = {
{ /// The ID of the custom feed /// The ID of the custom feed
Id : string Id : string
/// The source of the custom feed /// The source of the custom feed
Source : string Source : string
/// The relative path at which the custom feed is served /// The relative path at which the custom feed is served
Path : string Path : string
/// Whether this custom feed is for a podcast /// Whether this custom feed is for a podcast
IsPodcast : bool IsPodcast : bool
} }
/// Support functions for custom feed displays
module DisplayCustomFeed =
/// Create a display version from a custom feed /// 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 = let source =
match feed.Source with match feed.Source with
| Category (CategoryId catId) -> $"Category: {(cats |> Array.find (fun cat -> cat.Id = catId)).Name}" | 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 /// 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 let pageId = PageId.toString page.Id
{ Id = pageId { Id = pageId
AuthorId = WebLogUserId.toString page.AuthorId AuthorId = WebLogUserId.toString page.AuthorId
@ -148,7 +151,7 @@ type DisplayPage =
} }
/// Create a display page from a database page /// 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 _, extra = WebLog.hostAndPath webLog
let pageId = PageId.toString page.Id let pageId = PageId.toString page.Id
{ Id = pageId { Id = pageId
@ -166,20 +169,22 @@ type DisplayPage =
/// Information about a revision used for display /// Information about a revision used for display
[<NoComparison; NoEquality>] [<NoComparison; NoEquality>]
type DisplayRevision = type DisplayRevision = {
{ /// The as-of date/time for the revision /// The as-of date/time for the revision
AsOf : DateTime AsOf : DateTime
/// The as-of date/time for the revision in the web log's local time zone /// The as-of date/time for the revision in the web log's local time zone
AsOfLocal : DateTime AsOfLocal : DateTime
/// The format of the text of the revision /// The format of the text of the revision
Format : string Format : string
} }
with
/// Functions to support displaying revisions
module DisplayRevision =
/// Create a display revision from an actual revision /// Create a display revision from an actual revision
static member fromRevision webLog (rev : Revision) = let fromRevision webLog (rev : Revision) =
{ AsOf = rev.AsOf.ToDateTimeUtc () { AsOf = rev.AsOf.ToDateTimeUtc ()
AsOfLocal = WebLog.localTime webLog rev.AsOf AsOfLocal = WebLog.localTime webLog rev.AsOf
Format = MarkupText.sourceType rev.Text Format = MarkupText.sourceType rev.Text
@ -190,29 +195,31 @@ open System.IO
/// Information about a theme used for display /// Information about a theme used for display
[<NoComparison; NoEquality>] [<NoComparison; NoEquality>]
type DisplayTheme = type DisplayTheme = {
{ /// The ID / path slug of the theme /// The ID / path slug of the theme
Id : string Id : string
/// The name of the theme /// The name of the theme
Name : string Name : string
/// The version of the theme /// The version of the theme
Version : string Version : string
/// How many templates are contained in the theme /// How many templates are contained in the theme
TemplateCount : int TemplateCount : int
/// Whether the theme is in use by any web logs /// Whether the theme is in use by any web logs
IsInUse : bool IsInUse : bool
/// Whether the theme .zip file exists on the filesystem /// Whether the theme .zip file exists on the filesystem
IsOnDisk : bool IsOnDisk : bool
} }
with
/// Functions to support displaying themes
module DisplayTheme =
/// Create a display theme from a theme /// Create a display theme from a theme
static member fromTheme inUseFunc (theme : Theme) = let fromTheme inUseFunc (theme : Theme) =
{ Id = ThemeId.toString theme.Id { Id = ThemeId.toString theme.Id
Name = theme.Name Name = theme.Name
Version = theme.Version Version = theme.Version
@ -224,25 +231,28 @@ with
/// Information about an uploaded file used for display /// Information about an uploaded file used for display
[<NoComparison; NoEquality>] [<NoComparison; NoEquality>]
type DisplayUpload = type DisplayUpload = {
{ /// The ID of the uploaded file /// The ID of the uploaded file
Id : string Id : string
/// The name of the uploaded file /// The name of the uploaded file
Name : string Name : string
/// The path at which the file is served /// The path at which the file is served
Path : string Path : string
/// The date/time the file was updated /// The date/time the file was updated
UpdatedOn : DateTime option UpdatedOn : DateTime option
/// The source for this file (created from UploadDestination DU) /// The source for this file (created from UploadDestination DU)
Source : string Source : string
} }
/// Functions to support displaying uploads
module DisplayUpload =
/// Create a display uploaded file /// 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 path = Permalink.toString upload.Path
let name = Path.GetFileName path let name = Path.GetFileName path
{ Id = UploadId.toString upload.Id { Id = UploadId.toString upload.Id
@ -255,37 +265,40 @@ type DisplayUpload =
/// View model to display a user's information /// View model to display a user's information
[<NoComparison; NoEquality>] [<NoComparison; NoEquality>]
type DisplayUser = type DisplayUser = {
{ /// The ID of the user /// The ID of the user
Id : string Id : string
/// The user name (e-mail address) /// The user name (e-mail address)
Email : string Email : string
/// The user's first name /// The user's first name
FirstName : string FirstName : string
/// The user's last name /// The user's last name
LastName : string LastName : string
/// The user's preferred name /// The user's preferred name
PreferredName : string PreferredName : string
/// The URL of the user's personal site /// The URL of the user's personal site
Url : string Url : string
/// The user's access level /// The user's access level
AccessLevel : string AccessLevel : string
/// When the user was created /// When the user was created
CreatedOn : DateTime CreatedOn : DateTime
/// When the user last logged on /// When the user last logged on
LastSeenOn : Nullable<DateTime> LastSeenOn : Nullable<DateTime>
} }
/// Functions to support displaying a user's information
module DisplayUser =
/// Construct a displayed user from a web log user /// 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 { Id = WebLogUserId.toString user.Id
Email = user.Email Email = user.Email
FirstName = user.FirstName FirstName = user.FirstName

View File

@ -131,7 +131,7 @@ module PageListCache =
let private fillPages (webLog : WebLog) pages = let private fillPages (webLog : WebLog) pages =
_cache[webLog.Id] <- _cache[webLog.Id] <-
pages pages
|> List.map (fun pg -> DisplayPage.fromPage webLog { pg with Text = "" }) |> List.map (fun pg -> DisplayPage.FromPage webLog { pg with Text = "" })
|> Array.ofList |> Array.ofList
/// Are there pages cached for this web log? /// Are there pages cached for this web log?

View File

@ -15,7 +15,7 @@ let all pageNbr : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|> addToHash "pages" (pages |> addToHash "pages" (pages
|> Seq.ofList |> Seq.ofList
|> Seq.truncate 25 |> Seq.truncate 25
|> Seq.map (DisplayPage.fromPageMinimal ctx.WebLog) |> Seq.map (DisplayPage.FromPageMinimal ctx.WebLog)
|> List.ofSeq) |> List.ofSeq)
|> addToHash "page_nbr" pageNbr |> addToHash "page_nbr" pageNbr
|> addToHash "prev_page" (if pageNbr = 2 then "" else $"/page/{pageNbr - 1}") |> addToHash "prev_page" (if pageNbr = 2 then "" else $"/page/{pageNbr - 1}")

View File

@ -200,7 +200,7 @@ let home : HttpHandler = fun next ctx -> task {
| Some page -> | Some page ->
return! return!
hashForPage page.Title hashForPage page.Title
|> addToHash "page" (DisplayPage.fromPage webLog page) |> addToHash "page" (DisplayPage.FromPage webLog page)
|> addToHash ViewContext.IsHome true |> addToHash ViewContext.IsHome true
|> themedView (defaultArg page.Template "single-page") next ctx |> themedView (defaultArg page.Template "single-page") next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx

View File

@ -40,7 +40,7 @@ module CatchAll =
debug (fun () -> "Found page by permalink") debug (fun () -> "Found page by permalink")
yield fun next ctx -> yield fun next ctx ->
hashForPage page.Title hashForPage page.Title
|> addToHash "page" (DisplayPage.fromPage webLog page) |> addToHash "page" (DisplayPage.FromPage webLog page)
|> addToHash ViewContext.IsPage true |> addToHash ViewContext.IsPage true
|> themedView (defaultArg page.Template "single-page") next ctx |> themedView (defaultArg page.Template "single-page") next ctx
| None -> () | None -> ()