451 lines
14 KiB
Forth
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()
|