WIP on F#/RethinkDB conversion
73
src/MyWebLog.Data/Converters.fs
Normal file
|
@ -0,0 +1,73 @@
|
|||
/// JSON.NET converters for discriminated union types
|
||||
[<RequireQualifiedAccess>]
|
||||
module MyWebLog.JsonConverters
|
||||
|
||||
open MyWebLog
|
||||
open Newtonsoft.Json
|
||||
open System
|
||||
|
||||
type CategoryIdConverter () =
|
||||
inherit JsonConverter<CategoryId> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : CategoryId, _ : JsonSerializer) =
|
||||
writer.WriteValue (CategoryId.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : CategoryId, _ : bool, _ : JsonSerializer) =
|
||||
(string >> CategoryId) reader.Value
|
||||
|
||||
type CommentIdConverter () =
|
||||
inherit JsonConverter<CommentId> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : CommentId, _ : JsonSerializer) =
|
||||
writer.WriteValue (CommentId.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : CommentId, _ : bool, _ : JsonSerializer) =
|
||||
(string >> CommentId) reader.Value
|
||||
|
||||
type PermalinkConverter () =
|
||||
inherit JsonConverter<Permalink> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : Permalink, _ : JsonSerializer) =
|
||||
writer.WriteValue (Permalink.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : Permalink, _ : bool, _ : JsonSerializer) =
|
||||
(string >> Permalink) reader.Value
|
||||
|
||||
type PageIdConverter () =
|
||||
inherit JsonConverter<PageId> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : PageId, _ : JsonSerializer) =
|
||||
writer.WriteValue (PageId.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : PageId, _ : bool, _ : JsonSerializer) =
|
||||
(string >> PageId) reader.Value
|
||||
|
||||
type PostIdConverter () =
|
||||
inherit JsonConverter<PostId> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : PostId, _ : JsonSerializer) =
|
||||
writer.WriteValue (PostId.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : PostId, _ : bool, _ : JsonSerializer) =
|
||||
(string >> PostId) reader.Value
|
||||
|
||||
type WebLogIdConverter () =
|
||||
inherit JsonConverter<WebLogId> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : WebLogId, _ : JsonSerializer) =
|
||||
writer.WriteValue (WebLogId.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : WebLogId, _ : bool, _ : JsonSerializer) =
|
||||
(string >> WebLogId) reader.Value
|
||||
|
||||
type WebLogUserIdConverter () =
|
||||
inherit JsonConverter<WebLogUserId> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : WebLogUserId, _ : JsonSerializer) =
|
||||
writer.WriteValue (WebLogUserId.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : WebLogUserId, _ : bool, _ : JsonSerializer) =
|
||||
(string >> WebLogUserId) reader.Value
|
||||
|
||||
open Microsoft.FSharpLu.Json
|
||||
|
||||
/// All converters to use for data conversion
|
||||
let all () : JsonConverter seq =
|
||||
seq {
|
||||
CategoryIdConverter ()
|
||||
CommentIdConverter ()
|
||||
PermalinkConverter ()
|
||||
PageIdConverter ()
|
||||
PostIdConverter ()
|
||||
WebLogIdConverter ()
|
||||
WebLogUserIdConverter ()
|
||||
// Handles DUs with no associated data, as well as option fields
|
||||
CompactUnionJsonConverter ()
|
||||
}
|
||||
|
298
src/MyWebLog.Data/Data.fs
Normal file
|
@ -0,0 +1,298 @@
|
|||
[<RequireQualifiedAccess>]
|
||||
module MyWebLog.Data
|
||||
|
||||
/// Table names
|
||||
[<RequireQualifiedAccess>]
|
||||
module private Table =
|
||||
|
||||
/// The category table
|
||||
let Category = "Category"
|
||||
|
||||
/// The comment table
|
||||
let Comment = "Comment"
|
||||
|
||||
/// The page table
|
||||
let Page = "Page"
|
||||
|
||||
/// The post table
|
||||
let Post = "Post"
|
||||
|
||||
/// The web log table
|
||||
let WebLog = "WebLog"
|
||||
|
||||
/// The web log user table
|
||||
let WebLogUser = "WebLogUser"
|
||||
|
||||
/// A list of all tables
|
||||
let all = [ Category; Comment; Page; Post; WebLog; WebLogUser ]
|
||||
|
||||
|
||||
/// Functions to assist with retrieving data
|
||||
[<AutoOpen>]
|
||||
module Helpers =
|
||||
|
||||
open RethinkDb.Driver
|
||||
open RethinkDb.Driver.Net
|
||||
open System.Threading.Tasks
|
||||
|
||||
/// Shorthand for the ReQL starting point
|
||||
let r = RethinkDB.R
|
||||
|
||||
/// Verify that the web log ID matches before returning an item
|
||||
let verifyWebLog<'T> webLogId (prop : 'T -> WebLogId) (f : IConnection -> Task<'T option>) =
|
||||
fun conn -> task {
|
||||
match! f conn with Some it when (prop it) = webLogId -> return Some it | _ -> return None
|
||||
}
|
||||
|
||||
|
||||
open RethinkDb.Driver.FSharp
|
||||
open Microsoft.Extensions.Logging
|
||||
|
||||
module Startup =
|
||||
|
||||
/// Ensure field indexes exist, as well as special indexes for selected tables
|
||||
let private ensureIndexes (log : ILogger) conn table fields = task {
|
||||
let! indexes = rethink<string list> { withTable table; indexList; result; withRetryOnce conn }
|
||||
for field in fields do
|
||||
match indexes |> List.contains field with
|
||||
| true -> ()
|
||||
| false ->
|
||||
log.LogInformation($"Creating index {table}.{field}...")
|
||||
let! _ = rethink { withTable table; indexCreate field; write; withRetryOnce conn }
|
||||
()
|
||||
// Post and page need index by web log ID and permalink
|
||||
match [ Table.Page; Table.Post ] |> List.contains table with
|
||||
| true ->
|
||||
match indexes |> List.contains "permalink" with
|
||||
| true -> ()
|
||||
| false ->
|
||||
log.LogInformation($"Creating index {table}.permalink...")
|
||||
let! _ =
|
||||
rethink {
|
||||
withTable table
|
||||
indexCreate "permalink" (fun row -> r.Array(row.G "webLogId", row.G "permalink"))
|
||||
write
|
||||
withRetryOnce conn
|
||||
}
|
||||
()
|
||||
| false -> ()
|
||||
// Users log on with e-mail
|
||||
match Table.WebLogUser = table with
|
||||
| true ->
|
||||
match indexes |> List.contains "logOn" with
|
||||
| true -> ()
|
||||
| false ->
|
||||
log.LogInformation($"Creating index {table}.logOn...")
|
||||
let! _ =
|
||||
rethink {
|
||||
withTable table
|
||||
indexCreate "logOn" (fun row -> r.Array(row.G "webLogId", row.G "email"))
|
||||
write
|
||||
withRetryOnce conn
|
||||
}
|
||||
()
|
||||
| false -> ()
|
||||
}
|
||||
|
||||
/// Ensure all necessary tables and indexes exist
|
||||
let ensureDb (config : DataConfig) (log : ILogger) conn = task {
|
||||
|
||||
let! dbs = rethink<string list> { dbList; result; withRetryOnce conn }
|
||||
match dbs |> List.contains config.Database with
|
||||
| true -> ()
|
||||
| false ->
|
||||
log.LogInformation($"Creating database {config.Database}...")
|
||||
let! _ = rethink { dbCreate config.Database; write; withRetryOnce conn }
|
||||
()
|
||||
|
||||
let! tables = rethink<string list> { tableList; result; withRetryOnce conn }
|
||||
for tbl in Table.all do
|
||||
match tables |> List.contains tbl with
|
||||
| true -> ()
|
||||
| false ->
|
||||
log.LogInformation($"Creating table {tbl}...")
|
||||
let! _ = rethink { tableCreate tbl; write; withRetryOnce conn }
|
||||
()
|
||||
|
||||
let makeIdx = ensureIndexes log conn
|
||||
do! makeIdx Table.Category [ "webLogId" ]
|
||||
do! makeIdx Table.Comment [ "postId" ]
|
||||
do! makeIdx Table.Page [ "webLogId"; "authorId" ]
|
||||
do! makeIdx Table.Post [ "webLogId"; "authorId" ]
|
||||
do! makeIdx Table.WebLog [ "urlBase" ]
|
||||
do! makeIdx Table.WebLogUser [ "webLogId" ]
|
||||
}
|
||||
|
||||
/// Functions to manipulate categories
|
||||
module Category =
|
||||
|
||||
/// Count all categories for a web log
|
||||
let countAll (webLogId : WebLogId) =
|
||||
rethink<int> {
|
||||
withTable Table.Category
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
count
|
||||
result
|
||||
withRetryDefault
|
||||
}
|
||||
|
||||
/// Count top-level categories for a web log
|
||||
let countTopLevel (webLogId : WebLogId) =
|
||||
rethink<int> {
|
||||
withTable Table.Category
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
filter "parentId" None
|
||||
count
|
||||
result
|
||||
withRetryDefault
|
||||
}
|
||||
|
||||
|
||||
/// Functions to manipulate pages
|
||||
module Page =
|
||||
|
||||
/// Count all pages for a web log
|
||||
let countAll (webLogId : WebLogId) =
|
||||
rethink<int> {
|
||||
withTable Table.Page
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
count
|
||||
result
|
||||
withRetryDefault
|
||||
}
|
||||
|
||||
/// Count listed pages for a web log
|
||||
let countListed (webLogId : WebLogId) =
|
||||
rethink<int> {
|
||||
withTable Table.Page
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
filter "showInPageList" true
|
||||
count
|
||||
result
|
||||
withRetryDefault
|
||||
}
|
||||
|
||||
/// Retrieve all pages for a web log
|
||||
let findAll (webLogId : WebLogId) =
|
||||
rethink<Page list> {
|
||||
withTable Table.Page
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
without [ "priorPermalinks", "revisions" ]
|
||||
result
|
||||
withRetryDefault
|
||||
}
|
||||
|
||||
/// Find a page by its ID
|
||||
let findById (pageId : PageId) webLogId =
|
||||
rethink<Page> {
|
||||
withTable Table.Page
|
||||
get pageId
|
||||
without [ "priorPermalinks", "revisions" ]
|
||||
resultOption
|
||||
withRetryDefault
|
||||
}
|
||||
|> verifyWebLog webLogId (fun it -> it.webLogId)
|
||||
|
||||
/// Find a page by its permalink
|
||||
let findByPermalink (permalink : Permalink) (webLogId : WebLogId) =
|
||||
rethink<Page> {
|
||||
withTable Table.Page
|
||||
getAll [ r.Array (webLogId, permalink) ] (nameof permalink)
|
||||
without [ "priorPermalinks", "revisions" ]
|
||||
limit 1
|
||||
resultOption
|
||||
withRetryDefault
|
||||
}
|
||||
|
||||
/// Find a page by its ID (including permalinks and revisions)
|
||||
let findByFullId (pageId : PageId) webLogId =
|
||||
rethink<Page> {
|
||||
withTable Table.Page
|
||||
get pageId
|
||||
resultOption
|
||||
withRetryDefault
|
||||
}
|
||||
|> verifyWebLog webLogId (fun it -> it.webLogId)
|
||||
|
||||
/// Find a list of pages (displayed in admin area)
|
||||
let findPageOfPages (webLogId : WebLogId) pageNbr =
|
||||
rethink<Page list> {
|
||||
withTable Table.Page
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
without [ "priorPermalinks", "revisions" ]
|
||||
orderBy "title"
|
||||
skip ((pageNbr - 1) * 25)
|
||||
limit 25
|
||||
result
|
||||
withRetryDefault
|
||||
}
|
||||
|
||||
/// Functions to manipulate posts
|
||||
module Post =
|
||||
|
||||
/// Count posts for a web log by their status
|
||||
let countByStatus (status : PostStatus) (webLogId : WebLogId) =
|
||||
rethink<int> {
|
||||
withTable Table.Post
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
filter "status" status
|
||||
count
|
||||
result
|
||||
withRetryDefault
|
||||
}
|
||||
|
||||
/// Find a post by its permalink
|
||||
let findByPermalink (permalink : Permalink) (webLogId : WebLogId) =
|
||||
rethink<Post> {
|
||||
withTable Table.Post
|
||||
getAll [ r.Array(permalink, webLogId) ] (nameof permalink)
|
||||
without [ "priorPermalinks", "revisions" ]
|
||||
limit 1
|
||||
resultOption
|
||||
withRetryDefault
|
||||
}
|
||||
|
||||
/// Find posts to be displayed on a page
|
||||
let findPageOfPublishedPosts (webLogId : WebLogId) pageNbr postsPerPage =
|
||||
rethink<Post list> {
|
||||
withTable Table.Post
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
filter "status" Published
|
||||
without [ "priorPermalinks", "revisions" ]
|
||||
orderBy "publishedOn"
|
||||
skip ((pageNbr - 1) * postsPerPage)
|
||||
limit postsPerPage
|
||||
result
|
||||
withRetryDefault
|
||||
}
|
||||
|
||||
|
||||
/// Functions to manipulate web logs
|
||||
module WebLog =
|
||||
|
||||
/// Retrieve web log details by the URL base
|
||||
let findByHost (url : string) =
|
||||
rethink<WebLog> {
|
||||
withTable Table.WebLog
|
||||
getAll [ url ] "urlBase"
|
||||
limit 1
|
||||
resultOption
|
||||
withRetryDefault
|
||||
}
|
||||
|
||||
/// Update web log settings
|
||||
let updateSettings (webLog : WebLog) =
|
||||
rethink {
|
||||
withTable Table.WebLog
|
||||
get webLog.id
|
||||
update [
|
||||
"name", webLog.name
|
||||
"subtitle", webLog.subtitle
|
||||
"defaultPage", webLog.defaultPage
|
||||
"postsPerPage", webLog.postsPerPage
|
||||
"timeZone", webLog.timeZone
|
||||
]
|
||||
write
|
||||
withRetryDefault
|
||||
ignoreResult
|
||||
}
|
25
src/MyWebLog.Data/MyWebLog.Data.fsproj
Normal file
|
@ -0,0 +1,25 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\RethinkDb.Driver.FSharp\src\RethinkDb.Driver.FSharp\RethinkDb.Driver.FSharp.fsproj" />
|
||||
<ProjectReference Include="..\MyWebLog.Domain\MyWebLog.Domain.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="*" />
|
||||
<PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.7" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||
<PackageReference Include="RethinkDb.Driver" Version="2.3.150" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="Converters.fs" />
|
||||
<Compile Include="Data.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
|
@ -1,4 +1,4 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
309
src/MyWebLog.Domain/DataTypes.fs
Normal file
|
@ -0,0 +1,309 @@
|
|||
namespace MyWebLog
|
||||
|
||||
open System
|
||||
|
||||
/// A category under which a post may be identfied
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Category =
|
||||
{ /// The ID of the category
|
||||
id : CategoryId
|
||||
|
||||
/// The ID of the web log to which the category belongs
|
||||
webLogId : WebLogId
|
||||
|
||||
/// The displayed name
|
||||
name : string
|
||||
|
||||
/// The slug (used in category URLs)
|
||||
slug : string
|
||||
|
||||
/// A longer description of the category
|
||||
description : string option
|
||||
|
||||
/// The parent ID of this category (if a subcategory)
|
||||
parentId : CategoryId option
|
||||
}
|
||||
|
||||
/// Functions to support categories
|
||||
module Category =
|
||||
|
||||
/// An empty category
|
||||
let empty =
|
||||
{ id = CategoryId.empty
|
||||
webLogId = WebLogId.empty
|
||||
name = ""
|
||||
slug = ""
|
||||
description = None
|
||||
parentId = None
|
||||
}
|
||||
|
||||
|
||||
/// A comment on a post
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Comment =
|
||||
{ /// The ID of the comment
|
||||
id : CommentId
|
||||
|
||||
/// The ID of the post to which this comment applies
|
||||
postId : PostId
|
||||
|
||||
/// The ID of the comment to which this comment is a reply
|
||||
inReplyToId : CommentId option
|
||||
|
||||
/// The name of the commentor
|
||||
name : string
|
||||
|
||||
/// The e-mail address of the commentor
|
||||
email : string
|
||||
|
||||
/// The URL of the commentor's personal website
|
||||
url : string option
|
||||
|
||||
/// The status of the comment
|
||||
status : CommentStatus
|
||||
|
||||
/// When the comment was posted
|
||||
postedOn : DateTime
|
||||
|
||||
/// The text of the comment
|
||||
text : string
|
||||
}
|
||||
|
||||
/// Functions to support comments
|
||||
module Comment =
|
||||
|
||||
/// An empty comment
|
||||
let empty =
|
||||
{ id = CommentId.empty
|
||||
postId = PostId.empty
|
||||
inReplyToId = None
|
||||
name = ""
|
||||
email = ""
|
||||
url = None
|
||||
status = Pending
|
||||
postedOn = DateTime.UtcNow
|
||||
text = ""
|
||||
}
|
||||
|
||||
|
||||
/// A page (text not associated with a date/time)
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Page =
|
||||
{ /// The ID of this page
|
||||
id : PageId
|
||||
|
||||
/// The ID of the web log to which this page belongs
|
||||
webLogId : WebLogId
|
||||
|
||||
/// The ID of the author of this page
|
||||
authorId : WebLogUserId
|
||||
|
||||
/// The title of the page
|
||||
title : string
|
||||
|
||||
/// The link at which this page is displayed
|
||||
permalink : Permalink
|
||||
|
||||
/// When this page was published
|
||||
publishedOn : DateTime
|
||||
|
||||
/// When this page was last updated
|
||||
updatedOn : DateTime
|
||||
|
||||
/// Whether this page shows as part of the web log's navigation
|
||||
showInPageList : bool
|
||||
|
||||
/// The template to use when rendering this page
|
||||
template : string option
|
||||
|
||||
/// The current text of the page
|
||||
text : string
|
||||
|
||||
/// Permalinks at which this page may have been previously served (useful for migrated content)
|
||||
priorPermalinks : string list
|
||||
|
||||
/// Revisions of this page
|
||||
revisions : Revision list
|
||||
}
|
||||
|
||||
/// Functions to support pages
|
||||
module Page =
|
||||
|
||||
/// An empty page
|
||||
let empty =
|
||||
{ id = PageId.empty
|
||||
webLogId = WebLogId.empty
|
||||
authorId = WebLogUserId.empty
|
||||
title = ""
|
||||
permalink = Permalink.empty
|
||||
publishedOn = DateTime.MinValue
|
||||
updatedOn = DateTime.MinValue
|
||||
showInPageList = false
|
||||
template = None
|
||||
text = ""
|
||||
priorPermalinks = []
|
||||
revisions = []
|
||||
}
|
||||
|
||||
|
||||
/// A web log post
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Post =
|
||||
{ /// The ID of this post
|
||||
id : PostId
|
||||
|
||||
/// The ID of the web log to which this post belongs
|
||||
webLogId : WebLogId
|
||||
|
||||
/// The ID of the author of this post
|
||||
authorId : WebLogUserId
|
||||
|
||||
/// The status
|
||||
status : PostStatus
|
||||
|
||||
/// The title
|
||||
title : string
|
||||
|
||||
/// The link at which the post resides
|
||||
permalink : Permalink
|
||||
|
||||
/// The instant on which the post was originally published
|
||||
publishedOn : DateTime option
|
||||
|
||||
/// The instant on which the post was last updated
|
||||
updatedOn : DateTime
|
||||
|
||||
/// The text of the post in HTML (ready to display) format
|
||||
text : string
|
||||
|
||||
/// The Ids of the categories to which this is assigned
|
||||
categoryIds : CategoryId list
|
||||
|
||||
/// The tags for the post
|
||||
tags : string list
|
||||
|
||||
/// Permalinks at which this post may have been previously served (useful for migrated content)
|
||||
priorPermalinks : Permalink list
|
||||
|
||||
/// The revisions for this post
|
||||
revisions : Revision list
|
||||
}
|
||||
|
||||
/// Functions to support posts
|
||||
module Post =
|
||||
|
||||
/// An empty post
|
||||
let empty =
|
||||
{ id = PostId.empty
|
||||
webLogId = WebLogId.empty
|
||||
authorId = WebLogUserId.empty
|
||||
status = Draft
|
||||
title = ""
|
||||
permalink = Permalink.empty
|
||||
publishedOn = None
|
||||
updatedOn = DateTime.MinValue
|
||||
text = ""
|
||||
categoryIds = []
|
||||
tags = []
|
||||
priorPermalinks = []
|
||||
revisions = []
|
||||
}
|
||||
|
||||
|
||||
/// A web log
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type WebLog =
|
||||
{ /// The ID of the web log
|
||||
id : WebLogId
|
||||
|
||||
/// The name of the web log
|
||||
name : string
|
||||
|
||||
/// A subtitle for the web log
|
||||
subtitle : string option
|
||||
|
||||
/// The default page ("posts" or a page Id)
|
||||
defaultPage : string
|
||||
|
||||
/// The number of posts to display on pages of posts
|
||||
postsPerPage : int
|
||||
|
||||
/// The path of the theme (within /views/themes)
|
||||
themePath : string
|
||||
|
||||
/// The URL base
|
||||
urlBase : string
|
||||
|
||||
/// The time zone in which dates/times should be displayed
|
||||
timeZone : string
|
||||
}
|
||||
|
||||
/// Functions to support web logs
|
||||
module WebLog =
|
||||
|
||||
/// An empty set of web logs
|
||||
let empty =
|
||||
{ id = WebLogId.empty
|
||||
name = ""
|
||||
subtitle = None
|
||||
defaultPage = ""
|
||||
postsPerPage = 10
|
||||
themePath = "Default"
|
||||
urlBase = ""
|
||||
timeZone = ""
|
||||
}
|
||||
|
||||
/// Convert a permalink to an absolute URL
|
||||
let absoluteUrl webLog = function Permalink link -> $"{webLog.urlBase}{link}"
|
||||
|
||||
|
||||
/// A user of the web log
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type WebLogUser =
|
||||
{ /// The ID of the user
|
||||
id : WebLogUserId
|
||||
|
||||
/// The ID of the web log to which this user belongs
|
||||
webLogId : WebLogId
|
||||
|
||||
/// The user name (e-mail address)
|
||||
userName : string
|
||||
|
||||
/// The user's first name
|
||||
firstName : string
|
||||
|
||||
/// The user's last name
|
||||
lastName : string
|
||||
|
||||
/// The user's preferred name
|
||||
preferredName : string
|
||||
|
||||
/// The hash of the user's password
|
||||
passwordHash : string
|
||||
|
||||
/// Salt used to calculate the user's password hash
|
||||
salt : Guid
|
||||
|
||||
/// The URL of the user's personal site
|
||||
url : string option
|
||||
|
||||
/// The user's authorization level
|
||||
authorizationLevel : AuthorizationLevel
|
||||
}
|
||||
|
||||
/// Functions to support web log users
|
||||
module WebLogUser =
|
||||
|
||||
/// An empty web log user
|
||||
let empty =
|
||||
{ id = WebLogUserId.empty
|
||||
webLogId = WebLogId.empty
|
||||
userName = ""
|
||||
firstName = ""
|
||||
lastName = ""
|
||||
preferredName = ""
|
||||
passwordHash = ""
|
||||
salt = Guid.Empty
|
||||
url = None
|
||||
authorizationLevel = User
|
||||
}
|
13
src/MyWebLog.Domain/MyWebLog.Domain.fsproj
Normal file
|
@ -0,0 +1,13 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="SupportTypes.fs" />
|
||||
<Compile Include="DataTypes.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
181
src/MyWebLog.Domain/SupportTypes.fs
Normal file
|
@ -0,0 +1,181 @@
|
|||
namespace MyWebLog
|
||||
|
||||
open System
|
||||
|
||||
/// Support functions for domain definition
|
||||
[<AutoOpen>]
|
||||
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('+', '-')[..22]
|
||||
|
||||
|
||||
/// An identifier for a category
|
||||
type CategoryId = CategoryId of string
|
||||
|
||||
/// Functions to support category IDs
|
||||
module CategoryId =
|
||||
|
||||
/// An empty category ID
|
||||
let empty = CategoryId ""
|
||||
|
||||
/// Convert a category ID to a string
|
||||
let toString = function CategoryId ci -> ci
|
||||
|
||||
/// Create a new category ID
|
||||
let create () = CategoryId (newId ())
|
||||
|
||||
|
||||
/// An identifier for a comment
|
||||
type CommentId = CommentId of string
|
||||
|
||||
/// Functions to support comment IDs
|
||||
module CommentId =
|
||||
|
||||
/// An empty comment ID
|
||||
let empty = CommentId ""
|
||||
|
||||
/// Convert a comment ID to a string
|
||||
let toString = function CommentId ci -> ci
|
||||
|
||||
/// Create a new comment ID
|
||||
let create () = CommentId (newId ())
|
||||
|
||||
|
||||
/// Statuses for post comments
|
||||
type CommentStatus =
|
||||
/// The comment is approved
|
||||
| Approved
|
||||
/// The comment has yet to be approved
|
||||
| Pending
|
||||
/// The comment was unsolicited and unwelcome
|
||||
| Spam
|
||||
|
||||
|
||||
/// The source format for a revision
|
||||
type RevisionSource =
|
||||
/// Markdown text
|
||||
| Markdown
|
||||
/// HTML
|
||||
| Html
|
||||
|
||||
|
||||
/// A revision of a page or post
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Revision =
|
||||
{ /// When this revision was saved
|
||||
asOf : DateTime
|
||||
|
||||
/// The source language (Markdown or HTML)
|
||||
sourceType : RevisionSource
|
||||
|
||||
/// The text of the revision
|
||||
text : string
|
||||
}
|
||||
|
||||
/// Functions to support revisions
|
||||
module Revision =
|
||||
|
||||
/// An empty revision
|
||||
let empty =
|
||||
{ asOf = DateTime.UtcNow
|
||||
sourceType = Html
|
||||
text = ""
|
||||
}
|
||||
|
||||
|
||||
/// A permanent link
|
||||
type Permalink = Permalink of string
|
||||
|
||||
/// Functions to support permalinks
|
||||
module Permalink =
|
||||
|
||||
/// An empty permalink
|
||||
let empty = Permalink ""
|
||||
|
||||
/// Convert a permalink to a string
|
||||
let toString = function Permalink p -> p
|
||||
|
||||
|
||||
/// An identifier for a page
|
||||
type PageId = PageId of string
|
||||
|
||||
/// Functions to support page IDs
|
||||
module PageId =
|
||||
|
||||
/// An empty page ID
|
||||
let empty = PageId ""
|
||||
|
||||
/// Convert a page ID to a string
|
||||
let toString = function PageId pi -> pi
|
||||
|
||||
/// Create a new page ID
|
||||
let create () = PageId (newId ())
|
||||
|
||||
|
||||
/// Statuses for posts
|
||||
type PostStatus =
|
||||
/// The post should not be publicly available
|
||||
| Draft
|
||||
/// The post is publicly viewable
|
||||
| Published
|
||||
|
||||
|
||||
/// An identifier for a post
|
||||
type PostId = PostId of string
|
||||
|
||||
/// Functions to support post IDs
|
||||
module PostId =
|
||||
|
||||
/// An empty post ID
|
||||
let empty = PostId ""
|
||||
|
||||
/// Convert a post ID to a string
|
||||
let toString = function PostId pi -> pi
|
||||
|
||||
/// Create a new post ID
|
||||
let create () = PostId (newId ())
|
||||
|
||||
|
||||
/// An identifier for a web log
|
||||
type WebLogId = WebLogId of string
|
||||
|
||||
/// Functions to support web log IDs
|
||||
module WebLogId =
|
||||
|
||||
/// An empty web log ID
|
||||
let empty = WebLogId ""
|
||||
|
||||
/// Convert a web log ID to a string
|
||||
let toString = function WebLogId wli -> wli
|
||||
|
||||
/// Create a new web log ID
|
||||
let create () = WebLogId (newId ())
|
||||
|
||||
|
||||
/// A level of authorization for a given web log
|
||||
type AuthorizationLevel =
|
||||
/// <summary>The user may administer all aspects of a web log</summary>
|
||||
| Administrator
|
||||
/// <summary>The user is a known user of a web log</summary>
|
||||
| User
|
||||
|
||||
|
||||
/// An identifier for a web log user
|
||||
type WebLogUserId = WebLogUserId of string
|
||||
|
||||
/// Functions to support web log user IDs
|
||||
module WebLogUserId =
|
||||
|
||||
/// An empty web log user ID
|
||||
let empty = WebLogUserId ""
|
||||
|
||||
/// Convert a web log user ID to a string
|
||||
let toString = function WebLogUserId wli -> wli
|
||||
|
||||
/// Create a new web log user ID
|
||||
let create () = WebLogUserId (newId ())
|
||||
|
||||
|
63
src/MyWebLog.FS/Features/Admin/AdminController.fs
Normal file
|
@ -0,0 +1,63 @@
|
|||
namespace MyWebLog.Features.Admin
|
||||
|
||||
open Microsoft.AspNetCore.Authorization
|
||||
open Microsoft.AspNetCore.Mvc
|
||||
open Microsoft.AspNetCore.Mvc.Rendering
|
||||
open MyWebLog
|
||||
open MyWebLog.Features.Shared
|
||||
open RethinkDb.Driver.Net
|
||||
open System.Threading.Tasks
|
||||
|
||||
/// Controller for admin-specific displays and routes
|
||||
[<Route "/admin">]
|
||||
[<Authorize>]
|
||||
type AdminController () =
|
||||
inherit MyWebLogController ()
|
||||
|
||||
[<HttpGet "">]
|
||||
member this.Index () = task {
|
||||
let getCount (f : WebLogId -> IConnection -> Task<int>) = f this.WebLog.id this.Db
|
||||
let! posts = Data.Post.countByStatus Published |> getCount
|
||||
let! drafts = Data.Post.countByStatus Draft |> getCount
|
||||
let! pages = Data.Page.countAll |> getCount
|
||||
let! pages = Data.Page.countAll |> getCount
|
||||
let! listed = Data.Page.countListed |> getCount
|
||||
let! cats = Data.Category.countAll |> getCount
|
||||
let! topCats = Data.Category.countTopLevel |> getCount
|
||||
return this.View (DashboardModel (
|
||||
this.WebLog,
|
||||
Posts = posts,
|
||||
Drafts = drafts,
|
||||
Pages = pages,
|
||||
ListedPages = listed,
|
||||
Categories = cats,
|
||||
TopLevelCategories = topCats
|
||||
))
|
||||
}
|
||||
|
||||
[<HttpGet "settings">]
|
||||
member this.Settings() = task {
|
||||
let! allPages = Data.Page.findAll this.WebLog.id this.Db
|
||||
return this.View (SettingsModel (
|
||||
this.WebLog,
|
||||
DefaultPages =
|
||||
(Seq.singleton (SelectListItem ("- {Resources.FirstPageOfPosts} -", "posts"))
|
||||
|> Seq.append (allPages |> Seq.map (fun p -> SelectListItem (p.title, PageId.toString p.id))))
|
||||
))
|
||||
}
|
||||
|
||||
[<HttpPost "settings">]
|
||||
member this.SaveSettings (model : SettingsModel) = task {
|
||||
match! Data.WebLog.findByHost this.WebLog.urlBase this.Db with
|
||||
| Some webLog ->
|
||||
let updated = model.UpdateSettings webLog
|
||||
do! Data.WebLog.updateSettings updated this.Db
|
||||
|
||||
// Update cache
|
||||
WebLogCache.set (WebLogCache.hostToDb this.HttpContext) updated
|
||||
|
||||
// TODO: confirmation message
|
||||
|
||||
return this.RedirectToAction (nameof this.Index);
|
||||
| None -> return this.NotFound ()
|
||||
}
|
76
src/MyWebLog.FS/Features/Admin/AdminTypes.fs
Normal file
|
@ -0,0 +1,76 @@
|
|||
namespace MyWebLog.Features.Admin
|
||||
|
||||
open MyWebLog
|
||||
open MyWebLog.Features.Shared
|
||||
|
||||
/// The model used to display the dashboard
|
||||
type DashboardModel (webLog) =
|
||||
inherit MyWebLogModel (webLog)
|
||||
|
||||
/// The number of published posts
|
||||
member val Posts = 0 with get, set
|
||||
|
||||
/// The number of post drafts
|
||||
member val Drafts = 0 with get, set
|
||||
|
||||
/// The number of pages
|
||||
member val Pages = 0 with get, set
|
||||
|
||||
/// The number of pages in the page list
|
||||
member val ListedPages = 0 with get, set
|
||||
|
||||
/// The number of categories
|
||||
member val Categories = 0 with get, set
|
||||
|
||||
/// The top-level categories
|
||||
member val TopLevelCategories = 0 with get, set
|
||||
|
||||
|
||||
open Microsoft.AspNetCore.Mvc.Rendering
|
||||
open System.ComponentModel.DataAnnotations
|
||||
|
||||
/// View model for editing web log settings
|
||||
type SettingsModel (webLog) =
|
||||
inherit MyWebLogModel (webLog)
|
||||
|
||||
/// Default constructor
|
||||
[<System.Obsolete "Only used for model binding; use the WebLogDetails constructor">]
|
||||
new() = SettingsModel WebLog.empty
|
||||
|
||||
/// The name of the web log
|
||||
[<Required (AllowEmptyStrings = false)>]
|
||||
[<Display ( ResourceType = typeof<Resources>, Name = "Name")>]
|
||||
member val Name = webLog.name with get, set
|
||||
|
||||
/// The subtitle of the web log
|
||||
[<Display(ResourceType = typeof<Resources>, Name = "Subtitle")>]
|
||||
member val Subtitle = (defaultArg webLog.subtitle "") with get, set
|
||||
|
||||
/// The default page
|
||||
[<Required>]
|
||||
[<Display(ResourceType = typeof<Resources>, Name = "DefaultPage")>]
|
||||
member val DefaultPage = webLog.defaultPage with get, set
|
||||
|
||||
/// How many posts should appear on index pages
|
||||
[<Required>]
|
||||
[<Display(ResourceType = typeof<Resources>, Name = "PostsPerPage")>]
|
||||
[<Range(0, 50)>]
|
||||
member val PostsPerPage = webLog.postsPerPage with get, set
|
||||
|
||||
/// The time zone in which dates/times should be displayed
|
||||
[<Required>]
|
||||
[<Display(ResourceType = typeof<Resources>, Name = "TimeZone")>]
|
||||
member val TimeZone = webLog.timeZone with get, set
|
||||
|
||||
/// Possible values for the default page
|
||||
member val DefaultPages = Seq.empty<SelectListItem> with get, set
|
||||
|
||||
/// Update the settings object from the data in this form
|
||||
member this.UpdateSettings (settings : WebLog) =
|
||||
{ settings with
|
||||
name = this.Name
|
||||
subtitle = (match this.Subtitle with "" -> None | sub -> Some sub)
|
||||
defaultPage = this.DefaultPage
|
||||
postsPerPage = this.PostsPerPage
|
||||
timeZone = this.TimeZone
|
||||
}
|
61
src/MyWebLog.FS/Features/Admin/Index.cshtml
Normal file
|
@ -0,0 +1,61 @@
|
|||
@model DashboardModel
|
||||
@{
|
||||
Layout = "_AdminLayout";
|
||||
ViewBag.Title = Resources.Dashboard;
|
||||
}
|
||||
<article class="container pt-3">
|
||||
<div class="row">
|
||||
<section class="col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3">
|
||||
<div class="card">
|
||||
<header class="card-header text-white bg-primary">@Resources.Posts</header>
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle text-muted pb-3">
|
||||
@Resources.Published <span class="badge rounded-pill bg-secondary">@Model.Posts</span>
|
||||
@Resources.Drafts <span class="badge rounded-pill bg-secondary">@Model.Drafts</span>
|
||||
</h6>
|
||||
<a asp-action="All" asp-controller="Post" class="btn btn-secondary me-2">@Resources.ViewAll</a>
|
||||
<a asp-action="Edit" asp-controller="Post" asp-route-id="new" class="btn btn-primary">
|
||||
@Resources.WriteANewPost
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="col-lg-5 col-xl-4 pb-3">
|
||||
<div class="card">
|
||||
<header class="card-header text-white bg-primary">@Resources.Pages</header>
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle text-muted pb-3">
|
||||
@Resources.All <span class="badge rounded-pill bg-secondary">@Model.Pages</span>
|
||||
@Resources.ShownInPageList <span class="badge rounded-pill bg-secondary">@Model.ListedPages</span>
|
||||
</h6>
|
||||
<a asp-action="All" asp-controller="Page" class="btn btn-secondary me-2">@Resources.ViewAll</a>
|
||||
<a asp-action="Edit" asp-controller="Page" asp-route-id="new" class="btn btn-primary">
|
||||
@Resources.CreateANewPage
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="row">
|
||||
<section class="col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3">
|
||||
<div class="card">
|
||||
<header class="card-header text-white bg-secondary">@Resources.Categories</header>
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle text-muted pb-3">
|
||||
@Resources.All <span class="badge rounded-pill bg-secondary">@Model.Categories</span>
|
||||
@Resources.TopLevel <span class="badge rounded-pill bg-secondary">@Model.TopLevelCategories</span>
|
||||
</h6>
|
||||
<a asp-action="All" asp-controller="Category" class="btn btn-secondary me-2">@Resources.ViewAll</a>
|
||||
<a asp-action="Edit" asp-controller="Category" asp-route-id="new" class="btn btn-secondary">
|
||||
@Resources.AddANewCategory
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="row pb-3">
|
||||
<div class="col text-end">
|
||||
<a asp-action="Settings" class="btn btn-secondary">@Resources.ModifySettings</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
15
src/MyWebLog.FS/Features/Pages/PageTypes.fs
Normal file
|
@ -0,0 +1,15 @@
|
|||
namespace MyWebLog.Features.Pages
|
||||
|
||||
open MyWebLog
|
||||
open MyWebLog.Features.Shared
|
||||
|
||||
/// The model used to render a single page
|
||||
type SinglePageModel (page : Page, webLog) =
|
||||
inherit MyWebLogModel (webLog)
|
||||
|
||||
/// The page to be rendered
|
||||
member _.Page with get () = page
|
||||
|
||||
/// Is this the home page?
|
||||
member _.IsHome with get() = PageId.toString page.id = webLog.defaultPage
|
||||
|
65
src/MyWebLog.FS/Features/Posts/PostController.fs
Normal file
|
@ -0,0 +1,65 @@
|
|||
namespace MyWebLog.Features.Posts
|
||||
|
||||
open Microsoft.AspNetCore.Authorization
|
||||
open Microsoft.AspNetCore.Mvc
|
||||
open MyWebLog
|
||||
open MyWebLog.Features.Pages
|
||||
open MyWebLog.Features.Shared
|
||||
open System
|
||||
open System.Threading.Tasks
|
||||
|
||||
/// Handle post-related requests
|
||||
[<Route "/post">]
|
||||
[<Authorize>]
|
||||
type PostController () =
|
||||
inherit MyWebLogController ()
|
||||
|
||||
[<HttpGet "~/">]
|
||||
[<AllowAnonymous>]
|
||||
member this.Index () = task {
|
||||
match this.WebLog.defaultPage with
|
||||
| "posts" -> return! this.PageOfPosts 1
|
||||
| pageId ->
|
||||
match! Data.Page.findById (PageId pageId) this.WebLog.id this.Db with
|
||||
| Some page ->
|
||||
return this.ThemedView (defaultArg page.template "SinglePage", SinglePageModel (page, this.WebLog))
|
||||
| None -> return this.NotFound ()
|
||||
}
|
||||
|
||||
[<HttpGet "~/page/{pageNbr:int}">]
|
||||
[<AllowAnonymous>]
|
||||
member this.PageOfPosts (pageNbr : int) = task {
|
||||
let! posts = Data.Post.findPageOfPublishedPosts this.WebLog.id pageNbr this.WebLog.postsPerPage this.Db
|
||||
return this.ThemedView ("Index", MultiplePostModel (posts, this.WebLog))
|
||||
}
|
||||
|
||||
[<HttpGet "~/{*link}">]
|
||||
member this.CatchAll (link : string) = task {
|
||||
let permalink = Permalink link
|
||||
match! Data.Post.findByPermalink permalink this.WebLog.id this.Db with
|
||||
| Some post -> return this.NotFound ()
|
||||
// TODO: return via single-post action
|
||||
| None ->
|
||||
match! Data.Page.findByPermalink permalink this.WebLog.id this.Db with
|
||||
| Some page ->
|
||||
return this.ThemedView (defaultArg page.template "SinglePage", SinglePageModel (page, this.WebLog))
|
||||
| None ->
|
||||
|
||||
// TOOD: search prior permalinks for posts and pages
|
||||
|
||||
// We tried, we really tried...
|
||||
Console.Write($"Returning 404 for permalink |{permalink}|");
|
||||
return this.NotFound ()
|
||||
}
|
||||
|
||||
[<HttpGet "all">]
|
||||
member this.All () = task {
|
||||
do! Task.CompletedTask;
|
||||
NotImplementedException () |> raise
|
||||
}
|
||||
|
||||
[<HttpGet "{id}/edit">]
|
||||
member this.Edit(postId : string) = task {
|
||||
do! Task.CompletedTask;
|
||||
NotImplementedException () |> raise
|
||||
}
|
11
src/MyWebLog.FS/Features/Posts/PostTypes.fs
Normal file
|
@ -0,0 +1,11 @@
|
|||
namespace MyWebLog.Features.Posts
|
||||
|
||||
open MyWebLog
|
||||
open MyWebLog.Features.Shared
|
||||
|
||||
/// The model used to render multiple posts
|
||||
type MultiplePostModel (posts : Post seq, webLog) =
|
||||
inherit MyWebLogModel (webLog)
|
||||
|
||||
/// The posts to be rendered
|
||||
member _.Posts with get () = posts
|
45
src/MyWebLog.FS/Features/Shared/SharedTypes.fs
Normal file
|
@ -0,0 +1,45 @@
|
|||
namespace MyWebLog.Features.Shared
|
||||
|
||||
open Microsoft.AspNetCore.Mvc
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
open MyWebLog
|
||||
open RethinkDb.Driver.Net
|
||||
open System.Security.Claims
|
||||
|
||||
/// Base class for myWebLog controllers
|
||||
type MyWebLogController () =
|
||||
inherit Controller ()
|
||||
|
||||
/// The data context to use to fulfil this request
|
||||
member this.Db with get () = this.HttpContext.RequestServices.GetRequiredService<IConnection> ()
|
||||
|
||||
/// The details for the current web log
|
||||
member this.WebLog with get () = WebLogCache.getByCtx this.HttpContext
|
||||
|
||||
/// The ID of the currently authenticated user
|
||||
member this.UserId with get () =
|
||||
this.User.Claims
|
||||
|> Seq.tryFind (fun c -> c.Type = ClaimTypes.NameIdentifier)
|
||||
|> Option.map (fun c -> c.Value)
|
||||
|> Option.defaultValue ""
|
||||
|
||||
/// Retern a themed view
|
||||
member this.ThemedView (template : string, model : obj) : IActionResult =
|
||||
// TODO: get actual version
|
||||
this.ViewData["Version"] <- "2"
|
||||
this.View (template, model)
|
||||
|
||||
/// Return a 404 response
|
||||
member _.NotFound () : IActionResult =
|
||||
base.NotFound ()
|
||||
|
||||
/// Redirect to an action in this controller
|
||||
member _.RedirectToAction action : IActionResult =
|
||||
base.RedirectToAction action
|
||||
|
||||
|
||||
/// Base model class for myWebLog views
|
||||
type MyWebLogModel (webLog : WebLog) =
|
||||
|
||||
/// The details for the web log
|
||||
member _.WebLog with get () = webLog
|
38
src/MyWebLog.FS/MyWebLog.FS.fsproj
Normal file
|
@ -0,0 +1,38 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="WebLogCache.fs" />
|
||||
<Compile Include="Features\Shared\SharedTypes.fs" />
|
||||
<Compile Include="Features\Admin\AdminTypes.fs" />
|
||||
<Compile Include="Features\Admin\AdminController.fs" />
|
||||
<Compile Include="Features\Pages\PageTypes.fs" />
|
||||
<Compile Include="Features\Posts\PostTypes.fs" />
|
||||
<Compile Include="Features\Posts\PostController.fs" />
|
||||
<Compile Include="Program.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MyWebLog.Data\MyWebLog.Data.fsproj" />
|
||||
<ProjectReference Include="..\MyWebLog.Domain\MyWebLog.Domain.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="Resources.Designer.fs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Resources.resx</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="Resources.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Resources.Designer.fs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
175
src/MyWebLog.FS/Program.fs
Normal file
|
@ -0,0 +1,175 @@
|
|||
open Microsoft.AspNetCore.Mvc.Razor
|
||||
open System.Reflection
|
||||
|
||||
/// Types to support feature folders
|
||||
module FeatureSupport =
|
||||
|
||||
open Microsoft.AspNetCore.Mvc.ApplicationModels
|
||||
open System.Collections.Concurrent
|
||||
|
||||
/// A controller model convention that identifies the feature in which a controller exists
|
||||
type FeatureControllerModelConvention () =
|
||||
|
||||
/// A cache of controller types to features
|
||||
static let _features = ConcurrentDictionary<string, string> ()
|
||||
|
||||
/// Derive the feature name from the controller's type
|
||||
static let getFeatureName (typ : TypeInfo) : string option =
|
||||
let cacheKey = Option.ofObj typ.FullName |> Option.defaultValue ""
|
||||
match _features.ContainsKey cacheKey with
|
||||
| true -> Some _features[cacheKey]
|
||||
| false ->
|
||||
let tokens = cacheKey.Split '.'
|
||||
match tokens |> Array.contains "Features" with
|
||||
| true ->
|
||||
let feature = tokens |> Array.skipWhile (fun it -> it <> "Features") |> Array.skip 1 |> Array.tryHead
|
||||
match feature with
|
||||
| Some f ->
|
||||
_features[cacheKey] <- f
|
||||
feature
|
||||
| None -> None
|
||||
| false -> None
|
||||
|
||||
interface IControllerModelConvention with
|
||||
/// <inheritdoc />
|
||||
member _.Apply (controller: ControllerModel) =
|
||||
controller.Properties.Add("feature", getFeatureName controller.ControllerType)
|
||||
|
||||
|
||||
open Microsoft.AspNetCore.Mvc.Controllers
|
||||
|
||||
/// Expand the location token with the feature name
|
||||
type FeatureViewLocationExpander () =
|
||||
|
||||
interface IViewLocationExpander with
|
||||
|
||||
/// <inheritdoc />
|
||||
member _.ExpandViewLocations
|
||||
(context : ViewLocationExpanderContext, viewLocations : string seq) : string seq =
|
||||
if isNull context then nullArg (nameof context)
|
||||
if isNull viewLocations then nullArg (nameof viewLocations)
|
||||
match context.ActionContext.ActionDescriptor with
|
||||
| :? ControllerActionDescriptor as descriptor ->
|
||||
let feature = string descriptor.Properties["feature"]
|
||||
viewLocations |> Seq.map (fun location -> location.Replace ("{2}", feature))
|
||||
| _ -> invalidArg "context" "ActionDescriptor not found"
|
||||
|
||||
/// <inheritdoc />
|
||||
member _.PopulateValues(_ : ViewLocationExpanderContext) = ()
|
||||
|
||||
|
||||
open MyWebLog
|
||||
|
||||
/// Types to support themed views
|
||||
module ThemeSupport =
|
||||
|
||||
/// Expand the location token with the theme path
|
||||
type ThemeViewLocationExpander () =
|
||||
interface IViewLocationExpander with
|
||||
|
||||
/// <inheritdoc />
|
||||
member _.ExpandViewLocations
|
||||
(context : ViewLocationExpanderContext, viewLocations : string seq) : string seq =
|
||||
if isNull context then nullArg (nameof context)
|
||||
if isNull viewLocations then nullArg (nameof viewLocations)
|
||||
|
||||
viewLocations |> Seq.map (fun location -> location.Replace ("{3}", string context.Values["theme"]))
|
||||
|
||||
/// <inheritdoc />
|
||||
member _.PopulateValues (context : ViewLocationExpanderContext) =
|
||||
if isNull context then nullArg (nameof context)
|
||||
|
||||
context.Values["theme"] <- (WebLogCache.getByCtx context.ActionContext.HttpContext).themePath
|
||||
|
||||
|
||||
open Microsoft.AspNetCore.Http
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
|
||||
/// Custom middleware for this application
|
||||
module Middleware =
|
||||
|
||||
open RethinkDb.Driver.Net
|
||||
open System.Threading.Tasks
|
||||
|
||||
/// Middleware to derive the current web log
|
||||
type WebLogMiddleware (next : RequestDelegate) =
|
||||
|
||||
member _.InvokeAsync (context : HttpContext) : Task = task {
|
||||
let host = WebLogCache.hostToDb context
|
||||
|
||||
match WebLogCache.exists host with
|
||||
| true -> ()
|
||||
| false ->
|
||||
let conn = context.RequestServices.GetRequiredService<IConnection> ()
|
||||
match! Data.WebLog.findByHost (context.Request.Host.ToUriComponent ()) conn with
|
||||
| Some details -> WebLogCache.set host details
|
||||
| None -> ()
|
||||
|
||||
match WebLogCache.exists host with
|
||||
| true -> do! next.Invoke context
|
||||
| false -> context.Response.StatusCode <- 404
|
||||
}
|
||||
|
||||
|
||||
open Microsoft.AspNetCore.Authentication.Cookies
|
||||
open Microsoft.AspNetCore.Builder
|
||||
open Microsoft.Extensions.Hosting
|
||||
open Microsoft.AspNetCore.Mvc
|
||||
open System
|
||||
open System.IO
|
||||
|
||||
[<EntryPoint>]
|
||||
let main args =
|
||||
let builder = WebApplication.CreateBuilder(args)
|
||||
let _ =
|
||||
builder.Services
|
||||
.AddMvc(fun opts ->
|
||||
opts.Conventions.Add (FeatureSupport.FeatureControllerModelConvention ())
|
||||
opts.Filters.Add (AutoValidateAntiforgeryTokenAttribute ()))
|
||||
.AddRazorOptions(fun opts ->
|
||||
opts.ViewLocationFormats.Clear ()
|
||||
opts.ViewLocationFormats.Add "/Themes/{3}/{0}.cshtml"
|
||||
opts.ViewLocationFormats.Add "/Themes/{3}/Shared/{0}.cshtml"
|
||||
opts.ViewLocationFormats.Add "/Themes/Default/{0}.cshtml"
|
||||
opts.ViewLocationFormats.Add "/Themes/Default/Shared/{0}.cshtml"
|
||||
opts.ViewLocationFormats.Add "/Features/{2}/{1}/{0}.cshtml"
|
||||
opts.ViewLocationFormats.Add "/Features/{2}/{0}.cshtml"
|
||||
opts.ViewLocationFormats.Add "/Features/Shared/{0}.cshtml"
|
||||
opts.ViewLocationExpanders.Add (FeatureSupport.FeatureViewLocationExpander ())
|
||||
opts.ViewLocationExpanders.Add (ThemeSupport.ThemeViewLocationExpander ()))
|
||||
let _ =
|
||||
builder.Services
|
||||
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(fun opts ->
|
||||
opts.ExpireTimeSpan <- TimeSpan.FromMinutes 20.
|
||||
opts.SlidingExpiration <- true
|
||||
opts.AccessDeniedPath <- "/forbidden")
|
||||
let _ = builder.Services.AddAuthorization()
|
||||
let _ = builder.Services.AddSingleton<IHttpContextAccessor, HttpContextAccessor> ()
|
||||
(* builder.Services.AddDbContext<WebLogDbContext>(o =>
|
||||
{
|
||||
// TODO: can get from DI?
|
||||
var db = WebLogCache.HostToDb(new HttpContextAccessor().HttpContext!);
|
||||
// "empty";
|
||||
o.UseSqlite($"Data Source=Db/{db}.db");
|
||||
}); *)
|
||||
|
||||
// Load themes
|
||||
Directory.GetFiles (Directory.GetCurrentDirectory (), "MyWebLog.Themes.*.dll")
|
||||
|> Array.map Assembly.LoadFile
|
||||
|> ignore
|
||||
|
||||
let app = builder.Build ()
|
||||
|
||||
let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict))
|
||||
let _ = app.UseMiddleware<Middleware.WebLogMiddleware> ()
|
||||
let _ = app.UseAuthentication ()
|
||||
let _ = app.UseStaticFiles ()
|
||||
let _ = app.UseRouting ()
|
||||
let _ = app.UseAuthorization ()
|
||||
let _ = app.UseEndpoints (fun endpoints -> endpoints.MapControllers () |> ignore)
|
||||
|
||||
app.Run()
|
||||
|
||||
0 // Exit code
|
||||
|
28
src/MyWebLog.FS/Properties/launchSettings.json
Normal file
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:29920",
|
||||
"sslPort": 44344
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"MyWebLog.FS": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7134;http://localhost:5134",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
252
src/MyWebLog.FS/Resources.resx
Normal file
|
@ -0,0 +1,252 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="Actions" xml:space="preserve">
|
||||
<value>Actions</value>
|
||||
</data>
|
||||
<data name="AddANewCategory" xml:space="preserve">
|
||||
<value>Add a New Category</value>
|
||||
</data>
|
||||
<data name="AddANewPage" xml:space="preserve">
|
||||
<value>Add a New Page</value>
|
||||
</data>
|
||||
<data name="Admin" xml:space="preserve">
|
||||
<value>Admin</value>
|
||||
</data>
|
||||
<data name="All" xml:space="preserve">
|
||||
<value>All</value>
|
||||
</data>
|
||||
<data name="Categories" xml:space="preserve">
|
||||
<value>Categories</value>
|
||||
</data>
|
||||
<data name="CreateANewPage" xml:space="preserve">
|
||||
<value>Create a New Page</value>
|
||||
</data>
|
||||
<data name="Dashboard" xml:space="preserve">
|
||||
<value>Dashboard</value>
|
||||
</data>
|
||||
<data name="DateFormatString" xml:space="preserve">
|
||||
<value>MMMM d, yyyy</value>
|
||||
</data>
|
||||
<data name="DefaultPage" xml:space="preserve">
|
||||
<value>Default Page</value>
|
||||
</data>
|
||||
<data name="Drafts" xml:space="preserve">
|
||||
<value>Drafts</value>
|
||||
</data>
|
||||
<data name="Edit" xml:space="preserve">
|
||||
<value>Edit</value>
|
||||
</data>
|
||||
<data name="EditPage" xml:space="preserve">
|
||||
<value>Edit Page</value>
|
||||
</data>
|
||||
<data name="EmailAddress" xml:space="preserve">
|
||||
<value>E-mail Address</value>
|
||||
</data>
|
||||
<data name="FirstPageOfPosts" xml:space="preserve">
|
||||
<value>First Page of Posts</value>
|
||||
</data>
|
||||
<data name="InListQuestion" xml:space="preserve">
|
||||
<value>In List?</value>
|
||||
</data>
|
||||
<data name="LastUpdated" xml:space="preserve">
|
||||
<value>Last Updated</value>
|
||||
</data>
|
||||
<data name="LogOff" xml:space="preserve">
|
||||
<value>Log Off</value>
|
||||
</data>
|
||||
<data name="LogOn" xml:space="preserve">
|
||||
<value>Log On</value>
|
||||
</data>
|
||||
<data name="LogOnTo" xml:space="preserve">
|
||||
<value>Log On to</value>
|
||||
</data>
|
||||
<data name="ModifySettings" xml:space="preserve">
|
||||
<value>Modify Settings</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Name</value>
|
||||
</data>
|
||||
<data name="No" xml:space="preserve">
|
||||
<value>No</value>
|
||||
</data>
|
||||
<data name="Pages" xml:space="preserve">
|
||||
<value>Pages</value>
|
||||
</data>
|
||||
<data name="PageText" xml:space="preserve">
|
||||
<value>Page Text</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>Password</value>
|
||||
</data>
|
||||
<data name="Permalink" xml:space="preserve">
|
||||
<value>Permalink</value>
|
||||
</data>
|
||||
<data name="Posts" xml:space="preserve">
|
||||
<value>Posts</value>
|
||||
</data>
|
||||
<data name="PostsPerPage" xml:space="preserve">
|
||||
<value>Posts per Page</value>
|
||||
</data>
|
||||
<data name="Published" xml:space="preserve">
|
||||
<value>Published</value>
|
||||
</data>
|
||||
<data name="SaveChanges" xml:space="preserve">
|
||||
<value>Save Changes</value>
|
||||
</data>
|
||||
<data name="ShowInPageList" xml:space="preserve">
|
||||
<value>Show in Page List</value>
|
||||
</data>
|
||||
<data name="ShownInPageList" xml:space="preserve">
|
||||
<value>Shown in Page List</value>
|
||||
</data>
|
||||
<data name="Subtitle" xml:space="preserve">
|
||||
<value>Subtitle</value>
|
||||
</data>
|
||||
<data name="ThereAreXCategories" xml:space="preserve">
|
||||
<value>There are {0} categories</value>
|
||||
</data>
|
||||
<data name="ThereAreXPages" xml:space="preserve">
|
||||
<value>There are {0} pages</value>
|
||||
</data>
|
||||
<data name="ThereAreXPublishedPostsAndYDrafts" xml:space="preserve">
|
||||
<value>There are {0} published posts and {1} drafts</value>
|
||||
</data>
|
||||
<data name="TimeZone" xml:space="preserve">
|
||||
<value>Time Zone</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Title</value>
|
||||
</data>
|
||||
<data name="TopLevel" xml:space="preserve">
|
||||
<value>Top Level</value>
|
||||
</data>
|
||||
<data name="ViewAll" xml:space="preserve">
|
||||
<value>View All</value>
|
||||
</data>
|
||||
<data name="WebLogSettings" xml:space="preserve">
|
||||
<value>Web Log Settings</value>
|
||||
</data>
|
||||
<data name="WriteANewPost" xml:space="preserve">
|
||||
<value>Write a New Post</value>
|
||||
</data>
|
||||
<data name="Yes" xml:space="preserve">
|
||||
<value>Yes</value>
|
||||
</data>
|
||||
</root>
|
27
src/MyWebLog.FS/WebLogCache.fs
Normal file
|
@ -0,0 +1,27 @@
|
|||
/// <summary>
|
||||
/// In-memory cache of web log details
|
||||
/// </summary>
|
||||
/// <remarks>This is filled by the middleware via the first request for each host, and can be updated via the web log
|
||||
/// settings update page</remarks>
|
||||
module MyWebLog.WebLogCache
|
||||
|
||||
open Microsoft.AspNetCore.Http
|
||||
open System.Collections.Concurrent
|
||||
|
||||
/// The cache of web log details
|
||||
let private _cache = ConcurrentDictionary<string, WebLog> ()
|
||||
|
||||
/// Transform a hostname to a database name
|
||||
let hostToDb (ctx : HttpContext) = ctx.Request.Host.ToUriComponent().Replace (':', '_')
|
||||
|
||||
/// Does a host exist in the cache?
|
||||
let exists host = _cache.ContainsKey host
|
||||
|
||||
/// Get the details for a web log via its host
|
||||
let getByHost host = _cache[host]
|
||||
|
||||
/// Get the details for a web log via its host
|
||||
let getByCtx ctx = _cache[hostToDb ctx]
|
||||
|
||||
/// Set the details for a particular host
|
||||
let set host details = _cache[host] <- details
|
8
src/MyWebLog.FS/appsettings.Development.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
9
src/MyWebLog.FS/appsettings.json
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AddRazorSupportForMvc>true</AddRazorSupportForMvc>
|
||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Remove="Themes\BitBadger\solutions.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Themes\BitBadger\solutions.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MyWebLog\MyWebLog.csproj">
|
||||
<Private>false</Private>
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
</Project>
|
6
src/MyWebLog.Themes.BitBadger/Themes/_ViewImports.cshtml
Normal file
|
@ -0,0 +1,6 @@
|
|||
@namespace MyWebLog.Themes
|
||||
|
||||
@using MyWebLog.Features.Shared
|
||||
@using MyWebLog.Properties
|
||||
|
||||
@addTagHelper *, MyWebLog
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
@ -3,9 +3,17 @@ Microsoft Visual Studio Solution File, Format Version 12.00
|
|||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.1.32210.238
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyWebLog.Data", "MyWebLog.Data\MyWebLog.Data.csproj", "{0177C744-F913-4352-A0EC-478B4B0388C3}"
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyWebLog", "MyWebLog\MyWebLog.csproj", "{3139DA09-C999-465A-BC98-02FEC3BD7E88}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyWebLog", "MyWebLog\MyWebLog.csproj", "{3139DA09-C999-465A-BC98-02FEC3BD7E88}"
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyWebLog.Themes.BitBadger", "MyWebLog.Themes.BitBadger\MyWebLog.Themes.BitBadger.csproj", "{729F7AB3-2300-4390-B972-71D32FBBBF50}"
|
||||
EndProject
|
||||
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MyWebLog.FS", "MyWebLog.FS\MyWebLog.FS.fsproj", "{4D62F235-73BA-42A6-8AA1-29D0D046E115}"
|
||||
EndProject
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "MyWebLog.Domain", "MyWebLog.Domain\MyWebLog.Domain.fsproj", "{8CA99122-888A-4524-8C1B-685F0A4B7B4B}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyWebLog.DataCS", "MyWebLog.DataCS\MyWebLog.DataCS.csproj", "{C9129BED-E4AE-41BB-BDB2-5418B7F924CC}"
|
||||
EndProject
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "MyWebLog.Data", "MyWebLog.Data\MyWebLog.Data.fsproj", "{D284584D-2CB2-40C8-B605-6D0FD84D9D3D}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
|
@ -13,14 +21,30 @@ Global
|
|||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{0177C744-F913-4352-A0EC-478B4B0388C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0177C744-F913-4352-A0EC-478B4B0388C3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0177C744-F913-4352-A0EC-478B4B0388C3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0177C744-F913-4352-A0EC-478B4B0388C3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{3139DA09-C999-465A-BC98-02FEC3BD7E88}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{3139DA09-C999-465A-BC98-02FEC3BD7E88}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{3139DA09-C999-465A-BC98-02FEC3BD7E88}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{3139DA09-C999-465A-BC98-02FEC3BD7E88}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{729F7AB3-2300-4390-B972-71D32FBBBF50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{729F7AB3-2300-4390-B972-71D32FBBBF50}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{729F7AB3-2300-4390-B972-71D32FBBBF50}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{729F7AB3-2300-4390-B972-71D32FBBBF50}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4D62F235-73BA-42A6-8AA1-29D0D046E115}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4D62F235-73BA-42A6-8AA1-29D0D046E115}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4D62F235-73BA-42A6-8AA1-29D0D046E115}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4D62F235-73BA-42A6-8AA1-29D0D046E115}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{8CA99122-888A-4524-8C1B-685F0A4B7B4B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{8CA99122-888A-4524-8C1B-685F0A4B7B4B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8CA99122-888A-4524-8C1B-685F0A4B7B4B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8CA99122-888A-4524-8C1B-685F0A4B7B4B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C9129BED-E4AE-41BB-BDB2-5418B7F924CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C9129BED-E4AE-41BB-BDB2-5418B7F924CC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C9129BED-E4AE-41BB-BDB2-5418B7F924CC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C9129BED-E4AE-41BB-BDB2-5418B7F924CC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D284584D-2CB2-40C8-B605-6D0FD84D9D3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D284584D-2CB2-40C8-B605-6D0FD84D9D3D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D284584D-2CB2-40C8-B605-6D0FD84D9D3D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D284584D-2CB2-40C8-B605-6D0FD84D9D3D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
|
0
src/MyWebLog.v2/Data.fs
Normal file
490
src/MyWebLog.v2/Domain.fs
Normal file
|
@ -0,0 +1,490 @@
|
|||
namespace MyWebLog.Domain
|
||||
|
||||
// -- Supporting Types --
|
||||
|
||||
/// Types of markup text supported
|
||||
type MarkupText =
|
||||
/// Text in Markdown format
|
||||
| Markdown of string
|
||||
/// Text in HTML format
|
||||
| Html of string
|
||||
|
||||
/// Functions to support maniuplating markup text
|
||||
module MarkupText =
|
||||
/// Get the string representation of this markup text
|
||||
let toString it =
|
||||
match it with
|
||||
| Markdown x -> "Markdown", x
|
||||
| Html x -> "HTML", x
|
||||
||> sprintf "%s: %s"
|
||||
/// Get the HTML value of the text
|
||||
let toHtml = function
|
||||
| Markdown it -> sprintf "TODO: convert to HTML - %s" it
|
||||
| Html it -> it
|
||||
/// Parse a string representation to markup text
|
||||
let ofString (it : string) =
|
||||
match true with
|
||||
| _ when it.StartsWith "Markdown: " -> it.Substring 10 |> Markdown
|
||||
| _ when it.StartsWith "HTML: " -> it.Substring 6 |> Html
|
||||
| _ -> sprintf "Cannot determine text type - %s" it |> invalidOp
|
||||
|
||||
|
||||
/// Authorization levels
|
||||
type AuthorizationLevel =
|
||||
/// Authorization to administer a weblog
|
||||
| Administrator
|
||||
/// Authorization to comment on a weblog
|
||||
| User
|
||||
|
||||
/// Functions to support authorization levels
|
||||
module AuthorizationLevel =
|
||||
/// Get the string reprsentation of an authorization level
|
||||
let toString = function Administrator -> "Administrator" | User -> "User"
|
||||
/// Create an authorization level from a string
|
||||
let ofString it =
|
||||
match it with
|
||||
| "Administrator" -> Administrator
|
||||
| "User" -> User
|
||||
| _ -> sprintf "%s is not an authorization level" it |> invalidOp
|
||||
|
||||
|
||||
/// Post statuses
|
||||
type PostStatus =
|
||||
/// Post has not been released for public consumption
|
||||
| Draft
|
||||
/// Post is released
|
||||
| Published
|
||||
|
||||
/// Functions to support post statuses
|
||||
module PostStatus =
|
||||
/// Get the string representation of a post status
|
||||
let toString = function Draft -> "Draft" | Published -> "Published"
|
||||
/// Create a post status from a string
|
||||
let ofString it =
|
||||
match it with
|
||||
| "Draft" -> Draft
|
||||
| "Published" -> Published
|
||||
| _ -> sprintf "%s is not a post status" it |> invalidOp
|
||||
|
||||
|
||||
/// Comment statuses
|
||||
type CommentStatus =
|
||||
/// Comment is approved
|
||||
| Approved
|
||||
/// Comment has yet to be approved
|
||||
| Pending
|
||||
/// Comment was flagged as spam
|
||||
| Spam
|
||||
|
||||
/// Functions to support comment statuses
|
||||
module CommentStatus =
|
||||
/// Get the string representation of a comment status
|
||||
let toString = function Approved -> "Approved" | Pending -> "Pending" | Spam -> "Spam"
|
||||
/// Create a comment status from a string
|
||||
let ofString it =
|
||||
match it with
|
||||
| "Approved" -> Approved
|
||||
| "Pending" -> Pending
|
||||
| "Spam" -> Spam
|
||||
| _ -> sprintf "%s is not a comment status" it |> invalidOp
|
||||
|
||||
|
||||
/// Seconds since the Unix epoch
|
||||
type UnixSeconds = UnixSeconds of int64
|
||||
|
||||
/// Functions to support Unix seconds
|
||||
module UnixSeconds =
|
||||
/// Get the long (int64) representation of Unix seconds
|
||||
let toLong = function UnixSeconds it -> it
|
||||
/// Zero seconds past the epoch
|
||||
let none = UnixSeconds 0L
|
||||
|
||||
|
||||
// -- IDs --
|
||||
|
||||
open System
|
||||
|
||||
// See https://www.madskristensen.net/blog/A-shorter-and-URL-friendly-GUID for info on "short GUIDs"
|
||||
|
||||
/// A short GUID
|
||||
type ShortGuid = ShortGuid of Guid
|
||||
|
||||
/// Functions to support short GUIDs
|
||||
module ShortGuid =
|
||||
/// Encode a GUID into a short GUID
|
||||
let toString = function
|
||||
| ShortGuid guid ->
|
||||
Convert.ToBase64String(guid.ToByteArray ())
|
||||
.Replace("/", "_")
|
||||
.Replace("+", "-")
|
||||
.Substring (0, 22)
|
||||
/// Decode a short GUID into a GUID
|
||||
let ofString (it : string) =
|
||||
it.Replace("_", "/").Replace ("-", "+")
|
||||
|> (sprintf "%s==" >> Convert.FromBase64String >> Guid >> ShortGuid)
|
||||
/// Create a new short GUID
|
||||
let create () = (Guid.NewGuid >> ShortGuid) ()
|
||||
/// The empty short GUID
|
||||
let empty = ShortGuid Guid.Empty
|
||||
|
||||
|
||||
/// The ID of a category
|
||||
type CategoryId = CategoryId of ShortGuid
|
||||
|
||||
/// Functions to support category IDs
|
||||
module CategoryId =
|
||||
/// Get the string representation of a page ID
|
||||
let toString = function CategoryId it -> ShortGuid.toString it
|
||||
/// Create a category ID from its string representation
|
||||
let ofString = ShortGuid.ofString >> CategoryId
|
||||
/// An empty category ID
|
||||
let empty = CategoryId ShortGuid.empty
|
||||
|
||||
|
||||
/// The ID of a comment
|
||||
type CommentId = CommentId of ShortGuid
|
||||
|
||||
/// Functions to support comment IDs
|
||||
module CommentId =
|
||||
/// Get the string representation of a comment ID
|
||||
let toString = function CommentId it -> ShortGuid.toString it
|
||||
/// Create a comment ID from its string representation
|
||||
let ofString = ShortGuid.ofString >> CommentId
|
||||
/// An empty comment ID
|
||||
let empty = CommentId ShortGuid.empty
|
||||
|
||||
|
||||
/// The ID of a page
|
||||
type PageId = PageId of ShortGuid
|
||||
|
||||
/// Functions to support page IDs
|
||||
module PageId =
|
||||
/// Get the string representation of a page ID
|
||||
let toString = function PageId it -> ShortGuid.toString it
|
||||
/// Create a page ID from its string representation
|
||||
let ofString = ShortGuid.ofString >> PageId
|
||||
/// An empty page ID
|
||||
let empty = PageId ShortGuid.empty
|
||||
|
||||
|
||||
/// The ID of a post
|
||||
type PostId = PostId of ShortGuid
|
||||
|
||||
/// Functions to support post IDs
|
||||
module PostId =
|
||||
/// Get the string representation of a post ID
|
||||
let toString = function PostId it -> ShortGuid.toString it
|
||||
/// Create a post ID from its string representation
|
||||
let ofString = ShortGuid.ofString >> PostId
|
||||
/// An empty post ID
|
||||
let empty = PostId ShortGuid.empty
|
||||
|
||||
|
||||
/// The ID of a user
|
||||
type UserId = UserId of ShortGuid
|
||||
|
||||
/// Functions to support user IDs
|
||||
module UserId =
|
||||
/// Get the string representation of a user ID
|
||||
let toString = function UserId it -> ShortGuid.toString it
|
||||
/// Create a user ID from its string representation
|
||||
let ofString = ShortGuid.ofString >> UserId
|
||||
/// An empty user ID
|
||||
let empty = UserId ShortGuid.empty
|
||||
|
||||
|
||||
/// The ID of a web log
|
||||
type WebLogId = WebLogId of ShortGuid
|
||||
|
||||
/// Functions to support web log IDs
|
||||
module WebLogId =
|
||||
/// Get the string representation of a web log ID
|
||||
let toString = function WebLogId it -> ShortGuid.toString it
|
||||
/// Create a web log ID from its string representation
|
||||
let ofString = ShortGuid.ofString >> WebLogId
|
||||
/// An empty web log ID
|
||||
let empty = WebLogId ShortGuid.empty
|
||||
|
||||
|
||||
// -- Domain Entities --
|
||||
// fsharplint:disable RecordFieldNames
|
||||
|
||||
/// A revision of a post or page
|
||||
type Revision = {
|
||||
/// The instant which this revision was saved
|
||||
asOf : UnixSeconds
|
||||
/// The text
|
||||
text : MarkupText
|
||||
}
|
||||
with
|
||||
/// An empty revision
|
||||
static member empty =
|
||||
{ asOf = UnixSeconds.none
|
||||
text = Markdown ""
|
||||
}
|
||||
|
||||
|
||||
/// A page with static content
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Page = {
|
||||
/// The Id
|
||||
id : PageId
|
||||
/// The Id of the web log to which this page belongs
|
||||
webLogId : WebLogId
|
||||
/// The Id of the author of this page
|
||||
authorId : UserId
|
||||
/// The title of the page
|
||||
title : string
|
||||
/// The link at which this page is displayed
|
||||
permalink : string
|
||||
/// The instant this page was published
|
||||
publishedOn : UnixSeconds
|
||||
/// The instant this page was last updated
|
||||
updatedOn : UnixSeconds
|
||||
/// Whether this page shows as part of the web log's navigation
|
||||
showInPageList : bool
|
||||
/// The current text of the page
|
||||
text : MarkupText
|
||||
/// Revisions of this page
|
||||
revisions : Revision list
|
||||
}
|
||||
with
|
||||
static member empty =
|
||||
{ id = PageId.empty
|
||||
webLogId = WebLogId.empty
|
||||
authorId = UserId.empty
|
||||
title = ""
|
||||
permalink = ""
|
||||
publishedOn = UnixSeconds.none
|
||||
updatedOn = UnixSeconds.none
|
||||
showInPageList = false
|
||||
text = Markdown ""
|
||||
revisions = []
|
||||
}
|
||||
|
||||
|
||||
/// An entry in the list of pages displayed as part of the web log (derived via query)
|
||||
type PageListEntry = {
|
||||
/// The permanent link for the page
|
||||
permalink : string
|
||||
/// The title of the page
|
||||
title : string
|
||||
}
|
||||
|
||||
|
||||
/// A web log
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type WebLog = {
|
||||
/// The Id
|
||||
id : WebLogId
|
||||
/// The name
|
||||
name : string
|
||||
/// The subtitle
|
||||
subtitle : string option
|
||||
/// The default page ("posts" or a page Id)
|
||||
defaultPage : string
|
||||
/// The path of the theme (within /views/themes)
|
||||
themePath : string
|
||||
/// The URL base
|
||||
urlBase : string
|
||||
/// The time zone in which dates/times should be displayed
|
||||
timeZone : string
|
||||
/// A list of pages to be rendered as part of the site navigation (not stored)
|
||||
pageList : PageListEntry list
|
||||
}
|
||||
with
|
||||
/// An empty web log
|
||||
static member empty =
|
||||
{ id = WebLogId.empty
|
||||
name = ""
|
||||
subtitle = None
|
||||
defaultPage = ""
|
||||
themePath = "default"
|
||||
urlBase = ""
|
||||
timeZone = "America/New_York"
|
||||
pageList = []
|
||||
}
|
||||
|
||||
|
||||
/// An authorization between a user and a web log
|
||||
type Authorization = {
|
||||
/// The Id of the web log to which this authorization grants access
|
||||
webLogId : WebLogId
|
||||
/// The level of access granted by this authorization
|
||||
level : AuthorizationLevel
|
||||
}
|
||||
|
||||
|
||||
/// A user of myWebLog
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type User = {
|
||||
/// The Id
|
||||
id : UserId
|
||||
/// The user name (e-mail address)
|
||||
userName : string
|
||||
/// The first name
|
||||
firstName : string
|
||||
/// The last name
|
||||
lastName : string
|
||||
/// The user's preferred name
|
||||
preferredName : string
|
||||
/// The hash of the user's password
|
||||
passwordHash : string
|
||||
/// The URL of the user's personal site
|
||||
url : string option
|
||||
/// The user's authorizations
|
||||
authorizations : Authorization list
|
||||
}
|
||||
with
|
||||
/// An empty user
|
||||
static member empty =
|
||||
{ id = UserId.empty
|
||||
userName = ""
|
||||
firstName = ""
|
||||
lastName = ""
|
||||
preferredName = ""
|
||||
passwordHash = ""
|
||||
url = None
|
||||
authorizations = []
|
||||
}
|
||||
|
||||
/// Functions supporting users
|
||||
module User =
|
||||
/// Claims for this user
|
||||
let claims user =
|
||||
user.authorizations
|
||||
|> List.map (fun a -> sprintf "%s|%s" (WebLogId.toString a.webLogId) (AuthorizationLevel.toString a.level))
|
||||
|
||||
|
||||
/// A category to which posts may be assigned
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Category = {
|
||||
/// The Id
|
||||
id : CategoryId
|
||||
/// The Id of the web log to which this category belongs
|
||||
webLogId : WebLogId
|
||||
/// The displayed name
|
||||
name : string
|
||||
/// The slug (used in category URLs)
|
||||
slug : string
|
||||
/// A longer description of the category
|
||||
description : string option
|
||||
/// The parent Id of this category (if a subcategory)
|
||||
parentId : CategoryId option
|
||||
/// The categories for which this category is the parent
|
||||
children : CategoryId list
|
||||
}
|
||||
with
|
||||
/// An empty category
|
||||
static member empty =
|
||||
{ id = CategoryId.empty
|
||||
webLogId = WebLogId.empty
|
||||
name = ""
|
||||
slug = ""
|
||||
description = None
|
||||
parentId = None
|
||||
children = []
|
||||
}
|
||||
|
||||
|
||||
/// A comment (applies to a post)
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Comment = {
|
||||
/// The Id
|
||||
id : CommentId
|
||||
/// The Id of the post to which this comment applies
|
||||
postId : PostId
|
||||
/// The Id of the comment to which this comment is a reply
|
||||
inReplyToId : CommentId option
|
||||
/// The name of the commentor
|
||||
name : string
|
||||
/// The e-mail address of the commentor
|
||||
email : string
|
||||
/// The URL of the commentor's personal website
|
||||
url : string option
|
||||
/// The status of the comment
|
||||
status : CommentStatus
|
||||
/// The instant the comment was posted
|
||||
postedOn : UnixSeconds
|
||||
/// The text of the comment
|
||||
text : string
|
||||
}
|
||||
with
|
||||
static member empty =
|
||||
{ id = CommentId.empty
|
||||
postId = PostId.empty
|
||||
inReplyToId = None
|
||||
name = ""
|
||||
email = ""
|
||||
url = None
|
||||
status = Pending
|
||||
postedOn = UnixSeconds.none
|
||||
text = ""
|
||||
}
|
||||
|
||||
|
||||
/// A post
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Post = {
|
||||
/// The Id
|
||||
id : PostId
|
||||
/// The Id of the web log to which this post belongs
|
||||
webLogId : WebLogId
|
||||
/// The Id of the author of this post
|
||||
authorId : UserId
|
||||
/// The status
|
||||
status : PostStatus
|
||||
/// The title
|
||||
title : string
|
||||
/// The link at which the post resides
|
||||
permalink : string
|
||||
/// The instant on which the post was originally published
|
||||
publishedOn : UnixSeconds
|
||||
/// The instant on which the post was last updated
|
||||
updatedOn : UnixSeconds
|
||||
/// The text of the post
|
||||
text : MarkupText
|
||||
/// The Ids of the categories to which this is assigned
|
||||
categoryIds : CategoryId list
|
||||
/// The tags for the post
|
||||
tags : string list
|
||||
/// The permalinks at which this post may have once resided
|
||||
priorPermalinks : string list
|
||||
/// Revisions of this post
|
||||
revisions : Revision list
|
||||
/// The categories to which this is assigned (not stored in database)
|
||||
categories : Category list
|
||||
/// The comments (not stored in database)
|
||||
comments : Comment list
|
||||
}
|
||||
with
|
||||
static member empty =
|
||||
{ id = PostId.empty
|
||||
webLogId = WebLogId.empty
|
||||
authorId = UserId.empty
|
||||
status = Draft
|
||||
title = ""
|
||||
permalink = ""
|
||||
publishedOn = UnixSeconds.none
|
||||
updatedOn = UnixSeconds.none
|
||||
text = Markdown ""
|
||||
categoryIds = []
|
||||
tags = []
|
||||
priorPermalinks = []
|
||||
revisions = []
|
||||
categories = []
|
||||
comments = []
|
||||
}
|
||||
|
||||
|
||||
// --- UI Support ---
|
||||
|
||||
/// Counts of items displayed on the admin dashboard
|
||||
type DashboardCounts = {
|
||||
/// The number of pages for the web log
|
||||
pages : int
|
||||
/// The number of pages for the web log
|
||||
posts : int
|
||||
/// The number of categories for the web log
|
||||
categories : int
|
||||
}
|
28
src/MyWebLog.v2/MyWebLog.fsproj
Normal file
|
@ -0,0 +1,28 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="Strings.fs" />
|
||||
<Compile Include="Domain.fs" />
|
||||
<Compile Include="Program.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Remove="Resources/en-US.json" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Resources/en-US.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DotLiquid" Version="2.2.548" />
|
||||
<PackageReference Include="RethinkDb.Driver" Version="2.3.150" />
|
||||
<PackageReference Include="Suave" Version="2.6.1" />
|
||||
<PackageReference Include="Suave.DotLiquid" Version="2.6.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
4
src/MyWebLog.v2/Program.fs
Normal file
|
@ -0,0 +1,4 @@
|
|||
open MyWebLog
|
||||
open Suave
|
||||
|
||||
startWebServer defaultConfig (Successful.OK (Strings.get "LastUpdated"))
|
83
src/MyWebLog.v2/Resources/en-US.json
Normal file
|
@ -0,0 +1,83 @@
|
|||
{
|
||||
"Action": "Action",
|
||||
"Added": "Added",
|
||||
"AddNew": "Add New",
|
||||
"AddNewCategory": "Add New Category",
|
||||
"AddNewPage": "Add New Page",
|
||||
"AddNewPost": "Add New Post",
|
||||
"Admin": "Admin",
|
||||
"AndPublished": " and Published",
|
||||
"andXMore": "and {0} more...",
|
||||
"at": "at",
|
||||
"BackToCategoryList": "Back to Category List",
|
||||
"BackToPageList": "Back to Page List",
|
||||
"BackToPostList": "Back to Post List",
|
||||
"Categories": "Categories",
|
||||
"Category": "Category",
|
||||
"CategoryDeleteWarning": "Are you sure you wish to delete the category",
|
||||
"Close": "Close",
|
||||
"Comments": "Comments",
|
||||
"Dashboard": "Dashboard",
|
||||
"Date": "Date",
|
||||
"Delete": "Delete",
|
||||
"Description": "Description",
|
||||
"Edit": "Edit",
|
||||
"EditCategory": "Edit Category",
|
||||
"EditPage": "Edit Page",
|
||||
"EditPost": "Edit Post",
|
||||
"EmailAddress": "E-mail Address",
|
||||
"ErrBadAppConfig": "Could not convert config.json to myWebLog configuration",
|
||||
"ErrBadLogOnAttempt": "Invalid e-mail address or password",
|
||||
"ErrDataConfig": "Could not convert data-config.json to RethinkDB connection",
|
||||
"ErrNotConfigured": "is not properly configured for myWebLog",
|
||||
"Error": "Error",
|
||||
"LastUpdated": "Last Updated",
|
||||
"LastUpdatedDate": "Last Updated Date",
|
||||
"ListAll": "List All",
|
||||
"LoadedIn": "Loaded in",
|
||||
"LogOff": "Log Off",
|
||||
"LogOn": "Log On",
|
||||
"MsgCategoryDeleted": "Deleted category {0} successfully",
|
||||
"MsgCategoryEditSuccess": "{0} category successfully",
|
||||
"MsgLogOffSuccess": "Log off successful | Have a nice day!",
|
||||
"MsgLogOnSuccess": "Log on successful | Welcome to myWebLog!",
|
||||
"MsgPageDeleted": "Deleted page successfully",
|
||||
"MsgPageEditSuccess": "{0} page successfully",
|
||||
"MsgPostEditSuccess": "{0}{1} post successfully",
|
||||
"Name": "Name",
|
||||
"NewerPosts": "Newer Posts",
|
||||
"NextPost": "Next Post",
|
||||
"NoComments": "No Comments",
|
||||
"NoParent": "No Parent",
|
||||
"OlderPosts": "Older Posts",
|
||||
"OneComment": "1 Comment",
|
||||
"PageDeleteWarning": "Are you sure you wish to delete the page",
|
||||
"PageDetails": "Page Details",
|
||||
"PageHash": "Page #",
|
||||
"Pages": "Pages",
|
||||
"ParentCategory": "Parent Category",
|
||||
"Password": "Password",
|
||||
"Permalink": "Permalink",
|
||||
"PermanentLinkTo": "Permanent Link to",
|
||||
"PostDetails": "Post Details",
|
||||
"Posts": "Posts",
|
||||
"PostsTagged": "Posts Tagged",
|
||||
"PostStatus": "Post Status",
|
||||
"PoweredBy": "Powered by",
|
||||
"PreviousPost": "Previous Post",
|
||||
"PublishedDate": "Published Date",
|
||||
"PublishThisPost": "Publish This Post",
|
||||
"Save": "Save",
|
||||
"Seconds": "Seconds",
|
||||
"ShowInPageList": "Show in Page List",
|
||||
"Slug": "Slug",
|
||||
"startingWith": "starting with",
|
||||
"Status": "Status",
|
||||
"Tags": "Tags",
|
||||
"Time": "Time",
|
||||
"Title": "Title",
|
||||
"Updated": "Updated",
|
||||
"View": "View",
|
||||
"Warning": "Warning",
|
||||
"XComments": "{0} Comments"
|
||||
}
|
40
src/MyWebLog.v2/Strings.fs
Normal file
|
@ -0,0 +1,40 @@
|
|||
module MyWebLog.Strings
|
||||
|
||||
open System.Collections.Generic
|
||||
open System.Globalization
|
||||
open System.IO
|
||||
open System.Reflection
|
||||
open System.Text.Json
|
||||
|
||||
/// The locales we'll try to load
|
||||
let private supportedLocales = [ "en-US" ]
|
||||
|
||||
/// The fallback locale, if a key is not found in a non-default locale
|
||||
let private fallbackLocale = "en-US"
|
||||
|
||||
/// Get an embedded JSON file as a string
|
||||
let private getEmbedded locale =
|
||||
let str = sprintf "MyWebLog.Resources.%s.json" locale |> Assembly.GetExecutingAssembly().GetManifestResourceStream
|
||||
use rdr = new StreamReader (str)
|
||||
rdr.ReadToEnd()
|
||||
|
||||
/// The dictionary of localized strings
|
||||
let private strings =
|
||||
supportedLocales
|
||||
|> List.map (fun loc -> loc, getEmbedded loc |> JsonSerializer.Deserialize<Dictionary<string, string>>)
|
||||
|> dict
|
||||
|
||||
/// Get a key from the resources file for the given locale
|
||||
let getForLocale locale key =
|
||||
let getString thisLocale =
|
||||
match strings.ContainsKey thisLocale && strings.[thisLocale].ContainsKey key with
|
||||
| true -> Some strings.[thisLocale].[key]
|
||||
| false -> None
|
||||
match getString locale with
|
||||
| Some xlat -> Some xlat
|
||||
| None when locale <> fallbackLocale -> getString fallbackLocale
|
||||
| None -> None
|
||||
|> function Some xlat -> xlat | None -> sprintf "%s.%s" locale key
|
||||
|
||||
/// Translate the key for the current locale
|
||||
let get key = getForLocale CultureInfo.CurrentCulture.Name key
|
|
@ -31,7 +31,7 @@ public class ImageTagHelper : Microsoft.AspNetCore.Mvc.TagHelpers.ImageTagHelper
|
|||
return;
|
||||
}
|
||||
|
||||
output.Attributes.SetAttribute("src", $"~/img/{Theme}/{context.AllAttributes["src"]?.Value}");
|
||||
output.Attributes.SetAttribute("src", $"~/{Theme}/img/{context.AllAttributes["src"]?.Value}");
|
||||
ProcessUrlAttribute("src", output);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,11 +43,11 @@ public class LinkTagHelper : Microsoft.AspNetCore.Mvc.TagHelpers.LinkTagHelper
|
|||
switch (context.AllAttributes["rel"]?.Value.ToString())
|
||||
{
|
||||
case "stylesheet":
|
||||
output.Attributes.SetAttribute("href", $"~/css/{Theme}/{Style}.css");
|
||||
output.Attributes.SetAttribute("href", $"~/{Theme}/css/{Style}.css");
|
||||
break;
|
||||
case "icon":
|
||||
output.Attributes.SetAttribute("type", "image/x-icon");
|
||||
output.Attributes.SetAttribute("href", $"~/img/{Theme}/favicon.ico");
|
||||
output.Attributes.SetAttribute("href", $"~/{Theme}/img/favicon.ico");
|
||||
break;
|
||||
}
|
||||
ProcessUrlAttribute("href", output);
|
||||
|
|
|
@ -6,14 +6,6 @@
|
|||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Remove="Themes\BitBadger\solutions.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Themes\BitBadger\solutions.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="wwwroot\img\" />
|
||||
</ItemGroup>
|
||||
|
@ -27,7 +19,7 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MyWebLog.Data\MyWebLog.Data.csproj" />
|
||||
<ProjectReference Include="..\MyWebLog.DataCS\MyWebLog.DataCS.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
|||
using MyWebLog;
|
||||
using MyWebLog.Features;
|
||||
using MyWebLog.Features.Users;
|
||||
using System.Reflection;
|
||||
|
||||
if (args.Length > 0 && args[0] == "init")
|
||||
{
|
||||
|
@ -47,6 +48,10 @@ builder.Services.AddDbContext<WebLogDbContext>(o =>
|
|||
o.UseSqlite($"Data Source=Db/{db}.db");
|
||||
});
|
||||
|
||||
// Load themes
|
||||
Array.ForEach(Directory.GetFiles(Directory.GetCurrentDirectory(), "MyWebLog.Themes.*.dll"),
|
||||
it => { Assembly.LoadFile(it); });
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseCookiePolicy(new CookiePolicyOptions { MinimumSameSitePolicy = SameSiteMode.Strict });
|
||||
|
|