451 lines
14 KiB
Forth

namespace MyWebLog
open MyWebLog
open NodaTime
/// <summary>A category under which a post may be identified</summary>
[<CLIMutable; NoComparison; NoEquality>]
type Category = {
/// <summary>The ID of the category</summary>
Id: CategoryId
/// <summary>The ID of the web log to which the category belongs</summary>
WebLogId: WebLogId
/// <summary>The displayed name</summary>
Name: string
/// <summary>The slug (used in category URLs)</summary>
Slug: string
/// <summary>A longer description of the category</summary>
Description: string option
/// <summary>The parent ID of this category (if a subcategory)</summary>
ParentId: CategoryId option
} with
/// <summary>An empty category</summary>
static member Empty =
{ Id = CategoryId.Empty
WebLogId = WebLogId.Empty
Name = ""
Slug = ""
Description = None
ParentId = None }
/// <summary>A comment on a post</summary>
[<CLIMutable; NoComparison; NoEquality>]
type Comment = {
/// <summary>The ID of the comment</summary>
Id: CommentId
/// <summary>The ID of the post to which this comment applies</summary>
PostId: PostId
/// <summary>The ID of the comment to which this comment is a reply</summary>
InReplyToId: CommentId option
/// <summary>The name of the commentor</summary>
Name: string
/// <summary>The e-mail address of the commentor</summary>
Email: string
/// <summary>The URL of the commentor's personal website</summary>
Url: string option
/// <summary>The status of the comment</summary>
Status: CommentStatus
/// <summary>When the comment was posted</summary>
PostedOn: Instant
/// <summary>The text of the comment</summary>
Text: string
} with
/// <summary>An empty comment</summary>
static member Empty =
{ Id = CommentId.Empty
PostId = PostId.Empty
InReplyToId = None
Name = ""
Email = ""
Url = None
Status = Pending
PostedOn = Noda.epoch
Text = "" }
/// <summary>A page (text not associated with a date/time)</summary>
[<CLIMutable; NoComparison; NoEquality>]
type Page = {
/// <summary>The ID of this page</summary>
Id: PageId
/// <summary>The ID of the web log to which this page belongs</summary>
WebLogId: WebLogId
/// <summary>The ID of the author of this page</summary>
AuthorId: WebLogUserId
/// <summary>The title of the page</summary>
Title: string
/// <summary>The link at which this page is displayed</summary>
Permalink: Permalink
/// <summary>When this page was published</summary>
PublishedOn: Instant
/// <summary>When this page was last updated</summary>
UpdatedOn: Instant
/// <summary>Whether this page shows as part of the web log's navigation</summary>
IsInPageList: bool
/// <summary>The template to use when rendering this page</summary>
Template: string option
/// <summary>The current text of the page</summary>
Text: string
/// <summary>Metadata for this page</summary>
Metadata: MetaItem list
/// <summary>Permalinks at which this page may have been previously served (useful for migrated content)</summary>
PriorPermalinks: Permalink list
/// <summary>Revisions of this page</summary>
Revisions: Revision list
} with
/// <summary>An empty page</summary>
static member 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 = [] }
/// <summary>A web log post</summary>
[<CLIMutable; NoComparison; NoEquality>]
type Post = {
/// <summary>The ID of this post</summary>
Id: PostId
/// <summary>The ID of the web log to which this post belongs</summary>
WebLogId: WebLogId
/// <summary>The ID of the author of this post</summary>
AuthorId: WebLogUserId
/// <summary>The status</summary>
Status: PostStatus
/// <summary>The title</summary>
Title: string
/// <summary>The link at which the post resides</summary>
Permalink: Permalink
/// <summary>The instant on which the post was originally published</summary>
PublishedOn: Instant option
/// <summary>The instant on which the post was last updated</summary>
UpdatedOn: Instant
/// <summary>The template to use in displaying the post</summary>
Template: string option
/// <summary>The text of the post in HTML (ready to display) format</summary>
Text: string
/// <summary>The Ids of the categories to which this is assigned</summary>
CategoryIds: CategoryId list
/// <summary>The tags for the post</summary>
Tags: string list
/// <summary>Podcast episode information for this post</summary>
Episode: Episode option
/// <summary>Metadata for the post</summary>
Metadata: MetaItem list
/// <summary>Permalinks at which this post may have been previously served (useful for migrated content)</summary>
PriorPermalinks: Permalink list
/// <summary>The revisions for this post</summary>
Revisions: Revision list
} with
/// <summary>An empty post</summary>
static member 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 = [] }
/// <summary>
/// A mapping between a tag and its URL value, used to translate restricted characters (ex. "#1" -> "number-1")
/// </summary>
[<CLIMutable; NoComparison; NoEquality>]
type TagMap = {
/// <summary>The ID of this tag mapping</summary>
Id: TagMapId
/// <summary>The ID of the web log to which this tag mapping belongs</summary>
WebLogId: WebLogId
/// <summary>The tag which should be mapped to a different value in links</summary>
Tag: string
/// <summary>The value by which the tag should be linked</summary>
UrlValue: string
} with
/// <summary>An empty tag mapping</summary>
static member Empty =
{ Id = TagMapId.Empty; WebLogId = WebLogId.Empty; Tag = ""; UrlValue = "" }
/// <summary>A theme</summary>
[<CLIMutable; NoComparison; NoEquality>]
type Theme = {
/// <summary>The ID / path of the theme</summary>
Id: ThemeId
/// <summary>A long name of the theme</summary>
Name: string
/// <summary>The version of the theme</summary>
Version: string
/// <summary>The templates for this theme</summary>
Templates: ThemeTemplate list
} with
/// <summary>An empty theme</summary>
static member Empty =
{ Id = ThemeId.Empty; Name = ""; Version = ""; Templates = [] }
/// <summary>A theme asset (a file served as part of a theme, at /themes/[theme]/[asset-path])</summary>
[<CLIMutable; NoComparison; NoEquality>]
type ThemeAsset = {
/// <summary>The ID of the asset (consists of theme and path)</summary>
Id: ThemeAssetId
/// <summary>The updated date (set from the file date from the ZIP archive)</summary>
UpdatedOn: Instant
/// <summary>The data for the asset</summary>
Data: byte array
} with
/// <summary>An empty theme asset</summary>
static member Empty =
{ Id = ThemeAssetId.Empty; UpdatedOn = Noda.epoch; Data = [||] }
/// <summary>An uploaded file</summary>
[<CLIMutable; NoComparison; NoEquality>]
type Upload = {
/// <summary>The ID of the upload</summary>
Id: UploadId
/// <summary>The ID of the web log to which this upload belongs</summary>
WebLogId: WebLogId
/// <summary>The link at which this upload is served</summary>
Path: Permalink
/// <summary>The updated date/time for this upload</summary>
UpdatedOn: Instant
/// <summary>The data for the upload</summary>
Data: byte array
} with
/// <summary>An empty upload</summary>
static member Empty =
{ Id = UploadId.Empty; WebLogId = WebLogId.Empty; Path = Permalink.Empty; UpdatedOn = Noda.epoch; Data = [||] }
open Newtonsoft.Json
/// <summary>A web log</summary>
[<CLIMutable; NoComparison; NoEquality>]
type WebLog = {
/// <summary>The ID of the web log</summary>
Id: WebLogId
/// <summary>The name of the web log</summary>
Name: string
/// <summary>The slug of the web log</summary>
Slug: string
/// <summary>A subtitle for the web log</summary>
Subtitle: string option
/// <summary>The default page ("posts" or a page Id)</summary>
DefaultPage: string
/// <summary>The number of posts to display on pages of posts</summary>
PostsPerPage: int
/// <summary>The ID of the theme (also the path within /themes)</summary>
ThemeId: ThemeId
/// <summary>The URL base</summary>
UrlBase: string
/// <summary>The time zone in which dates/times should be displayed</summary>
TimeZone: string
/// <summary>The RSS options for this web log</summary>
Rss: RssOptions
/// <summary>Whether to automatically load htmx</summary>
AutoHtmx: bool
/// <summary>Where uploads are placed</summary>
Uploads: UploadDestination
/// <summary>Redirect rules for this weblog</summary>
RedirectRules: RedirectRule list
} with
/// <summary>An empty web log</summary>
static member Empty =
{ Id = WebLogId.Empty
Name = ""
Slug = ""
Subtitle = None
DefaultPage = ""
PostsPerPage = 10
ThemeId = ThemeId "default"
UrlBase = ""
TimeZone = ""
Rss = RssOptions.Empty
AutoHtmx = false
Uploads = Database
RedirectRules = [] }
/// <summary>
/// Any extra path where this web log is hosted (blank if web log is hosted at the root of the domain)
/// </summary>
[<JsonIgnore>]
member this.ExtraPath =
let pathParts = this.UrlBase.Split "://"
if pathParts.Length < 2 then
""
else
let path = pathParts[1].Split "/"
if path.Length > 1 then $"""/{path |> Array.skip 1 |> String.concat "/"}""" else ""
/// <summary>Generate an absolute URL for the given link</summary>
/// <param name="permalink">The permalink for which an absolute URL should be generated</param>
/// <returns>An absolute URL for the given link</returns>
member this.AbsoluteUrl(permalink: Permalink) =
$"{this.UrlBase}/{permalink}"
/// <summary>Generate a relative URL for the given link</summary>
/// <param name="permalink">The permalink for which a relative URL should be generated</param>
/// <returns>A relative URL for the given link</returns>
member this.RelativeUrl(permalink: Permalink) =
$"{this.ExtraPath}/{permalink}"
/// <summary>Convert an Instant (UTC reference) to the web log's local date/time</summary>
/// <param name="date">The UTC <c>Instant</c> to be converted</param>
/// <returns>The local date/time for this web log</returns>
member this.LocalTime(date: Instant) =
DateTimeZoneProviders.Tzdb.GetZoneOrNull this.TimeZone
|> Option.ofObj
|> Option.map (fun tz -> date.InZone(tz).ToDateTimeUnspecified())
|> Option.defaultValue (date.ToDateTimeUtc())
/// <summary>A user of the web log</summary>
[<CLIMutable; NoComparison; NoEquality>]
type WebLogUser = {
/// <summary>The ID of the user</summary>
Id: WebLogUserId
/// <summary>The ID of the web log to which this user belongs</summary>
WebLogId: WebLogId
/// <summary>The user name (e-mail address)</summary>
Email: string
/// <summary>The user's first name</summary>
FirstName: string
/// <summary>The user's last name</summary>
LastName: string
/// <summary>The user's preferred name</summary>
PreferredName: string
/// <summary>The hash of the user's password</summary>
PasswordHash: string
/// <summary>The URL of the user's personal site</summary>
Url: string option
/// <summary>The user's access level</summary>
AccessLevel: AccessLevel
/// <summary>When the user was created</summary>
CreatedOn: Instant
/// <summary>When the user last logged on</summary>
LastSeenOn: Instant option
} with
/// <summary>An empty web log user</summary>
static member Empty =
{ Id = WebLogUserId.Empty
WebLogId = WebLogId.Empty
Email = ""
FirstName = ""
LastName = ""
PreferredName = ""
PasswordHash = ""
Url = None
AccessLevel = Author
CreatedOn = Noda.epoch
LastSeenOn = None }
/// <summary>Get the user's displayed name</summary>
[<JsonIgnore>]
member this.DisplayName =
(seq { (match this.PreferredName with "" -> this.FirstName | n -> n); " "; this.LastName }
|> Seq.reduce (+)).Trim()