Version 2.1 #41
@ -2,6 +2,63 @@
|
||||
[<AutoOpen>]
|
||||
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 Microsoft.Data.Sqlite
|
||||
open MyWebLog
|
||||
|
@ -27,17 +27,9 @@ type SQLiteData (conn : SqliteConnection, log : ILogger<SQLiteData>, 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<SQLiteData>, 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<SQLiteData>, 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<SQLiteData>, 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<SQLiteData>, 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)
|
||||
}
|
||||
|
@ -6,8 +6,8 @@ open NodaTime
|
||||
|
||||
/// A category under which a post may be identified
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Category =
|
||||
{ /// The ID of the category
|
||||
type Category = {
|
||||
/// The ID of the category
|
||||
Id : CategoryId
|
||||
|
||||
/// The ID of the web log to which the category belongs
|
||||
@ -30,8 +30,8 @@ type Category =
|
||||
module Category =
|
||||
|
||||
/// An empty category
|
||||
let empty =
|
||||
{ Id = CategoryId.empty
|
||||
let empty = {
|
||||
Id = CategoryId.empty
|
||||
WebLogId = WebLogId.empty
|
||||
Name = ""
|
||||
Slug = ""
|
||||
@ -42,8 +42,8 @@ module Category =
|
||||
|
||||
/// A comment on a post
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Comment =
|
||||
{ /// The ID of the comment
|
||||
type Comment = {
|
||||
/// The ID of the comment
|
||||
Id : CommentId
|
||||
|
||||
/// The ID of the post to which this comment applies
|
||||
@ -75,8 +75,8 @@ type Comment =
|
||||
module Comment =
|
||||
|
||||
/// An empty comment
|
||||
let empty =
|
||||
{ Id = CommentId.empty
|
||||
let empty = {
|
||||
Id = CommentId.empty
|
||||
PostId = PostId.empty
|
||||
InReplyToId = None
|
||||
Name = ""
|
||||
@ -90,8 +90,8 @@ module Comment =
|
||||
|
||||
/// A page (text not associated with a date/time)
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Page =
|
||||
{ /// The ID of this page
|
||||
type Page = {
|
||||
/// The ID of this page
|
||||
Id : PageId
|
||||
|
||||
/// The ID of the web log to which this page belongs
|
||||
@ -135,8 +135,8 @@ type Page =
|
||||
module Page =
|
||||
|
||||
/// An empty page
|
||||
let empty =
|
||||
{ Id = PageId.empty
|
||||
let empty = {
|
||||
Id = PageId.empty
|
||||
WebLogId = WebLogId.empty
|
||||
AuthorId = WebLogUserId.empty
|
||||
Title = ""
|
||||
@ -154,8 +154,8 @@ module Page =
|
||||
|
||||
/// A web log post
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Post =
|
||||
{ /// The ID of this post
|
||||
type Post = {
|
||||
/// The ID of this post
|
||||
Id : PostId
|
||||
|
||||
/// The ID of the web log to which this post belongs
|
||||
@ -208,8 +208,8 @@ type Post =
|
||||
module Post =
|
||||
|
||||
/// An empty post
|
||||
let empty =
|
||||
{ Id = PostId.empty
|
||||
let empty = {
|
||||
Id = PostId.empty
|
||||
WebLogId = WebLogId.empty
|
||||
AuthorId = WebLogUserId.empty
|
||||
Status = Draft
|
||||
@ -229,8 +229,8 @@ module Post =
|
||||
|
||||
|
||||
/// 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
|
||||
type TagMap = {
|
||||
/// The ID of this tag mapping
|
||||
Id : TagMapId
|
||||
|
||||
/// The ID of the web log to which this tag mapping belongs
|
||||
@ -247,8 +247,8 @@ type TagMap =
|
||||
module TagMap =
|
||||
|
||||
/// An empty tag mapping
|
||||
let empty =
|
||||
{ Id = TagMapId.empty
|
||||
let empty = {
|
||||
Id = TagMapId.empty
|
||||
WebLogId = WebLogId.empty
|
||||
Tag = ""
|
||||
UrlValue = ""
|
||||
@ -256,8 +256,8 @@ module TagMap =
|
||||
|
||||
|
||||
/// A theme
|
||||
type Theme =
|
||||
{ /// The ID / path of the theme
|
||||
type Theme = {
|
||||
/// The ID / path of the theme
|
||||
Id : ThemeId
|
||||
|
||||
/// A long name of the theme
|
||||
@ -274,8 +274,8 @@ type Theme =
|
||||
module Theme =
|
||||
|
||||
/// An empty theme
|
||||
let empty =
|
||||
{ Id = ThemeId ""
|
||||
let empty = {
|
||||
Id = ThemeId ""
|
||||
Name = ""
|
||||
Version = ""
|
||||
Templates = []
|
||||
@ -283,8 +283,7 @@ module Theme =
|
||||
|
||||
|
||||
/// 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)
|
||||
Id : ThemeAssetId
|
||||
|
||||
@ -299,16 +298,16 @@ type ThemeAsset =
|
||||
module ThemeAsset =
|
||||
|
||||
/// An empty theme asset
|
||||
let empty =
|
||||
{ Id = ThemeAssetId (ThemeId "", "")
|
||||
let empty = {
|
||||
Id = ThemeAssetId (ThemeId "", "")
|
||||
UpdatedOn = Noda.epoch
|
||||
Data = [||]
|
||||
}
|
||||
|
||||
|
||||
/// An uploaded file
|
||||
type Upload =
|
||||
{ /// The ID of the upload
|
||||
type Upload = {
|
||||
/// The ID of the upload
|
||||
Id : UploadId
|
||||
|
||||
/// The ID of the web log to which this upload belongs
|
||||
@ -328,8 +327,8 @@ type Upload =
|
||||
module Upload =
|
||||
|
||||
/// An empty upload
|
||||
let empty =
|
||||
{ Id = UploadId.empty
|
||||
let empty = {
|
||||
Id = UploadId.empty
|
||||
WebLogId = WebLogId.empty
|
||||
Path = Permalink.empty
|
||||
UpdatedOn = Noda.epoch
|
||||
@ -339,8 +338,8 @@ module Upload =
|
||||
|
||||
/// A web log
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type WebLog =
|
||||
{ /// The ID of the web log
|
||||
type WebLog = {
|
||||
/// The ID of the web log
|
||||
Id : WebLogId
|
||||
|
||||
/// The name of the web log
|
||||
@ -384,8 +383,8 @@ type WebLog =
|
||||
module WebLog =
|
||||
|
||||
/// An empty web log
|
||||
let empty =
|
||||
{ Id = WebLogId.empty
|
||||
let empty = {
|
||||
Id = WebLogId.empty
|
||||
Name = ""
|
||||
Slug = ""
|
||||
Subtitle = None
|
||||
@ -424,8 +423,8 @@ module WebLog =
|
||||
|
||||
/// A user of the web log
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type WebLogUser =
|
||||
{ /// The ID of the user
|
||||
type WebLogUser = {
|
||||
/// The ID of the user
|
||||
Id : WebLogUserId
|
||||
|
||||
/// The ID of the web log to which this user belongs
|
||||
@ -463,8 +462,8 @@ type WebLogUser =
|
||||
module WebLogUser =
|
||||
|
||||
/// An empty web log user
|
||||
let empty =
|
||||
{ Id = WebLogUserId.empty
|
||||
let empty = {
|
||||
Id = WebLogUserId.empty
|
||||
WebLogId = WebLogId.empty
|
||||
Email = ""
|
||||
FirstName = ""
|
||||
|
@ -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
|
||||
@ -22,18 +22,17 @@ 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())
|
||||
|
||||
/// 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,12 +157,12 @@ 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)
|
||||
type Location = {
|
||||
/// The name of the location (free-form text)
|
||||
Name : string
|
||||
|
||||
/// A geographic coordinate string (RFC 5870)
|
||||
@ -175,8 +174,8 @@ type Location =
|
||||
|
||||
|
||||
/// A chapter in a podcast episode
|
||||
type Chapter =
|
||||
{ /// The start time for the chapter
|
||||
type Chapter = {
|
||||
/// The start time for the chapter
|
||||
StartTime : Duration
|
||||
|
||||
/// The title for this chapter
|
||||
@ -199,8 +198,8 @@ type Chapter =
|
||||
open NodaTime.Text
|
||||
|
||||
/// A podcast episode
|
||||
type Episode =
|
||||
{ /// The URL to the media file for the episode (may be permalink)
|
||||
type Episode = {
|
||||
/// The URL to the media file for the episode (may be permalink)
|
||||
Media : string
|
||||
|
||||
/// The length of the media file, in bytes
|
||||
@ -259,8 +258,8 @@ type Episode =
|
||||
module Episode =
|
||||
|
||||
/// An empty episode
|
||||
let empty =
|
||||
{ Media = ""
|
||||
let empty = {
|
||||
Media = ""
|
||||
Length = 0L
|
||||
Duration = None
|
||||
MediaType = None
|
||||
@ -316,15 +315,15 @@ module MarkupText =
|
||||
/// 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
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type MetaItem =
|
||||
{ /// The name of the metadata value
|
||||
type MetaItem = {
|
||||
/// The name of the metadata value
|
||||
Name : string
|
||||
|
||||
/// The metadata value
|
||||
@ -340,8 +339,8 @@ module MetaItem =
|
||||
|
||||
/// A revision of a page or post
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Revision =
|
||||
{ /// When this revision was saved
|
||||
type Revision = {
|
||||
/// When this revision was saved
|
||||
AsOf : Instant
|
||||
|
||||
/// The text of the revision
|
||||
@ -353,9 +352,7 @@ 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,12 +455,12 @@ 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
|
||||
type RedirectRule = {
|
||||
/// The From string or pattern
|
||||
From : string
|
||||
|
||||
/// The To string or pattern
|
||||
@ -477,8 +474,8 @@ type RedirectRule =
|
||||
module RedirectRule =
|
||||
|
||||
/// An empty redirect rule
|
||||
let empty =
|
||||
{ From = ""
|
||||
let empty = {
|
||||
From = ""
|
||||
To = ""
|
||||
IsRegex = false
|
||||
}
|
||||
@ -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,8 +522,8 @@ module CustomFeedSource =
|
||||
|
||||
|
||||
/// Options for a feed that describes a podcast
|
||||
type PodcastOptions =
|
||||
{ /// The title of the podcast
|
||||
type PodcastOptions = {
|
||||
/// The title of the podcast
|
||||
Title : string
|
||||
|
||||
/// A subtitle for the podcast
|
||||
@ -577,8 +574,8 @@ type PodcastOptions =
|
||||
|
||||
|
||||
/// A custom feed
|
||||
type CustomFeed =
|
||||
{ /// The ID of the custom feed
|
||||
type CustomFeed = {
|
||||
/// The ID of the custom feed
|
||||
Id : CustomFeedId
|
||||
|
||||
/// The source for the custom feed
|
||||
@ -595,8 +592,8 @@ type CustomFeed =
|
||||
module CustomFeed =
|
||||
|
||||
/// An empty custom feed
|
||||
let empty =
|
||||
{ Id = CustomFeedId ""
|
||||
let empty = {
|
||||
Id = CustomFeedId ""
|
||||
Source = Category (CategoryId "")
|
||||
Path = Permalink ""
|
||||
Podcast = None
|
||||
@ -605,8 +602,8 @@ module CustomFeed =
|
||||
|
||||
/// Really Simple Syndication (RSS) options for this web log
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type RssOptions =
|
||||
{ /// Whether the site feed of posts is enabled
|
||||
type RssOptions = {
|
||||
/// Whether the site feed of posts is enabled
|
||||
IsFeedEnabled : bool
|
||||
|
||||
/// The name of the file generated for the site feed
|
||||
@ -632,8 +629,8 @@ type RssOptions =
|
||||
module RssOptions =
|
||||
|
||||
/// An empty set of RSS options
|
||||
let empty =
|
||||
{ IsFeedEnabled = true
|
||||
let empty = {
|
||||
IsFeedEnabled = true
|
||||
FeedName = "feed.xml"
|
||||
ItemsInFeed = None
|
||||
IsCategoryEnabled = true
|
||||
@ -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,8 +680,8 @@ module ThemeAssetId =
|
||||
|
||||
|
||||
/// A template for a theme
|
||||
type ThemeTemplate =
|
||||
{ /// The name of the template
|
||||
type ThemeTemplate = {
|
||||
/// The name of the template
|
||||
Name : string
|
||||
|
||||
/// The text of the template
|
||||
@ -696,9 +693,7 @@ 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
|
||||
|
||||
|
||||
|
@ -26,8 +26,8 @@ module PublicHelpers =
|
||||
|
||||
/// The model used to display the admin dashboard
|
||||
[<NoComparison; NoEquality>]
|
||||
type DashboardModel =
|
||||
{ /// The number of published posts
|
||||
type DashboardModel = {
|
||||
/// The number of published posts
|
||||
Posts : int
|
||||
|
||||
/// The number of post drafts
|
||||
@ -49,8 +49,8 @@ type DashboardModel =
|
||||
|
||||
/// Details about a category, used to display category lists
|
||||
[<NoComparison; NoEquality>]
|
||||
type DisplayCategory =
|
||||
{ /// The ID of the category
|
||||
type DisplayCategory = {
|
||||
/// The ID of the category
|
||||
Id : string
|
||||
|
||||
/// The slug for the category
|
||||
@ -71,8 +71,8 @@ type DisplayCategory =
|
||||
|
||||
|
||||
/// A display version of a custom feed definition
|
||||
type DisplayCustomFeed =
|
||||
{ /// The ID of the custom feed
|
||||
type DisplayCustomFeed = {
|
||||
/// The ID of the custom feed
|
||||
Id : string
|
||||
|
||||
/// The source of the custom feed
|
||||
@ -85,8 +85,11 @@ type DisplayCustomFeed =
|
||||
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,8 +169,8 @@ type DisplayPage =
|
||||
|
||||
/// Information about a revision used for display
|
||||
[<NoComparison; NoEquality>]
|
||||
type DisplayRevision =
|
||||
{ /// The as-of date/time for the revision
|
||||
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
|
||||
@ -176,10 +179,12 @@ type DisplayRevision =
|
||||
/// The format of the text of the revision
|
||||
Format : string
|
||||
}
|
||||
with
|
||||
|
||||
/// 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,8 +195,8 @@ open System.IO
|
||||
|
||||
/// Information about a theme used for display
|
||||
[<NoComparison; NoEquality>]
|
||||
type DisplayTheme =
|
||||
{ /// The ID / path slug of the theme
|
||||
type DisplayTheme = {
|
||||
/// The ID / path slug of the theme
|
||||
Id : string
|
||||
|
||||
/// The name of the theme
|
||||
@ -209,10 +214,12 @@ type DisplayTheme =
|
||||
/// Whether the theme .zip file exists on the filesystem
|
||||
IsOnDisk : bool
|
||||
}
|
||||
with
|
||||
|
||||
/// 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,8 +231,8 @@ with
|
||||
|
||||
/// Information about an uploaded file used for display
|
||||
[<NoComparison; NoEquality>]
|
||||
type DisplayUpload =
|
||||
{ /// The ID of the uploaded file
|
||||
type DisplayUpload = {
|
||||
/// The ID of the uploaded file
|
||||
Id : string
|
||||
|
||||
/// The name of the uploaded file
|
||||
@ -241,8 +248,11 @@ type DisplayUpload =
|
||||
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,8 +265,8 @@ type DisplayUpload =
|
||||
|
||||
/// View model to display a user's information
|
||||
[<NoComparison; NoEquality>]
|
||||
type DisplayUser =
|
||||
{ /// The ID of the user
|
||||
type DisplayUser = {
|
||||
/// The ID of the user
|
||||
Id : string
|
||||
|
||||
/// The user name (e-mail address)
|
||||
@ -284,8 +294,11 @@ type DisplayUser =
|
||||
LastSeenOn : Nullable<DateTime>
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
@ -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?
|
||||
|
@ -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}")
|
||||
|
@ -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
|
||||
|
@ -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 -> ()
|
||||
|
Loading…
Reference in New Issue
Block a user