V2 #1
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>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<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
|
After Width: | Height: | Size: 23 KiB |
After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 6.4 KiB |
After Width: | Height: | Size: 9.3 KiB |
After Width: | Height: | Size: 54 KiB |
After Width: | Height: | Size: 57 KiB |
After Width: | Height: | Size: 69 KiB |
After Width: | Height: | Size: 70 KiB |
After Width: | Height: | Size: 90 KiB |
After Width: | Height: | Size: 70 KiB |
After Width: | Height: | Size: 68 KiB |
After Width: | Height: | Size: 38 KiB |
After Width: | Height: | Size: 23 KiB |
After Width: | Height: | Size: 47 KiB |
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 90 KiB |
After Width: | Height: | Size: 42 KiB |
After Width: | Height: | Size: 71 KiB |
After Width: | Height: | Size: 34 KiB |
After Width: | Height: | Size: 60 KiB |
After Width: | Height: | Size: 95 KiB |
After Width: | Height: | Size: 44 KiB |
After Width: | Height: | Size: 10 KiB |
|
@ -3,9 +3,17 @@ Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio Version 17
|
# Visual Studio Version 17
|
||||||
VisualStudioVersion = 17.1.32210.238
|
VisualStudioVersion = 17.1.32210.238
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
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
|
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
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
@ -13,14 +21,30 @@ Global
|
||||||
Release|Any CPU = Release|Any CPU
|
Release|Any CPU = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
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.ActiveCfg = Debug|Any CPU
|
||||||
{3139DA09-C999-465A-BC98-02FEC3BD7E88}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{3139DA09-C999-465A-BC98-02FEC3BD7E88}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
output.Attributes.SetAttribute("src", $"~/img/{Theme}/{context.AllAttributes["src"]?.Value}");
|
output.Attributes.SetAttribute("src", $"~/{Theme}/img/{context.AllAttributes["src"]?.Value}");
|
||||||
ProcessUrlAttribute("src", output);
|
ProcessUrlAttribute("src", output);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,11 +43,11 @@ public class LinkTagHelper : Microsoft.AspNetCore.Mvc.TagHelpers.LinkTagHelper
|
||||||
switch (context.AllAttributes["rel"]?.Value.ToString())
|
switch (context.AllAttributes["rel"]?.Value.ToString())
|
||||||
{
|
{
|
||||||
case "stylesheet":
|
case "stylesheet":
|
||||||
output.Attributes.SetAttribute("href", $"~/css/{Theme}/{Style}.css");
|
output.Attributes.SetAttribute("href", $"~/{Theme}/css/{Style}.css");
|
||||||
break;
|
break;
|
||||||
case "icon":
|
case "icon":
|
||||||
output.Attributes.SetAttribute("type", "image/x-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;
|
break;
|
||||||
}
|
}
|
||||||
ProcessUrlAttribute("href", output);
|
ProcessUrlAttribute("href", output);
|
||||||
|
|
|
@ -6,14 +6,6 @@
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Content Remove="Themes\BitBadger\solutions.json" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<EmbeddedResource Include="Themes\BitBadger\solutions.json" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="wwwroot\img\" />
|
<Folder Include="wwwroot\img\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
@ -27,7 +19,7 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\MyWebLog.Data\MyWebLog.Data.csproj" />
|
<ProjectReference Include="..\MyWebLog.DataCS\MyWebLog.DataCS.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
||||||
using MyWebLog;
|
using MyWebLog;
|
||||||
using MyWebLog.Features;
|
using MyWebLog.Features;
|
||||||
using MyWebLog.Features.Users;
|
using MyWebLog.Features.Users;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
if (args.Length > 0 && args[0] == "init")
|
if (args.Length > 0 && args[0] == "init")
|
||||||
{
|
{
|
||||||
|
@ -47,6 +48,10 @@ builder.Services.AddDbContext<WebLogDbContext>(o =>
|
||||||
o.UseSqlite($"Data Source=Db/{db}.db");
|
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();
|
var app = builder.Build();
|
||||||
|
|
||||||
app.UseCookiePolicy(new CookiePolicyOptions { MinimumSameSitePolicy = SameSiteMode.Strict });
|
app.UseCookiePolicy(new CookiePolicyOptions { MinimumSameSitePolicy = SameSiteMode.Strict });
|
||||||
|
|