Version 2, ready for beta
This commit is contained in:
parent
33dccf5822
commit
0f66ca969d
12
.gitignore
vendored
12
.gitignore
vendored
|
@ -253,7 +253,11 @@ paket-files/
|
|||
.idea/
|
||||
*.sln.iml
|
||||
|
||||
# Personal themes used to test initial release
|
||||
src/MyWebLog/views/themes/daniel-j-summers
|
||||
src/MyWebLog/views/themes/daniels-weekly-devotions
|
||||
src/MyWebLog/views/themes/djs-consulting
|
||||
# Zipped myWebLog themes
|
||||
**/*.zip
|
||||
|
||||
# Files used for testing
|
||||
src/MyWebLog/wwwroot/img/daniel-j-summers
|
||||
src/MyWebLog/wwwroot/img/bit-badger
|
||||
|
||||
.ionide
|
|
@ -1,155 +0,0 @@
|
|||
module MyWebLog.App
|
||||
|
||||
open MyWebLog
|
||||
open MyWebLog.Data
|
||||
open MyWebLog.Data.RethinkDB
|
||||
open MyWebLog.Entities
|
||||
open MyWebLog.Logic.WebLog
|
||||
open MyWebLog.Resources
|
||||
open Nancy
|
||||
open Nancy.Authentication.Forms
|
||||
open Nancy.Bootstrapper
|
||||
open Nancy.Conventions
|
||||
open Nancy.Cryptography
|
||||
open Nancy.Owin
|
||||
open Nancy.Security
|
||||
open Nancy.Session.Persistable
|
||||
//open Nancy.Session.Relational
|
||||
open Nancy.Session.RethinkDB
|
||||
open Nancy.TinyIoc
|
||||
open Nancy.ViewEngines.SuperSimpleViewEngine
|
||||
open NodaTime
|
||||
open RethinkDb.Driver.Net
|
||||
open Suave
|
||||
open Suave.Owin
|
||||
open System
|
||||
open System.IO
|
||||
open System.Reflection
|
||||
open System.Security.Claims
|
||||
open System.Text.RegularExpressions
|
||||
|
||||
/// Establish the configuration for this instance
|
||||
let cfg = try AppConfig.FromJson (System.IO.File.ReadAllText "config.json")
|
||||
with ex -> raise <| Exception (Strings.get "ErrBadAppConfig", ex)
|
||||
|
||||
let data = lazy (RethinkMyWebLogData (cfg.DataConfig.Conn, cfg.DataConfig) :> IMyWebLogData)
|
||||
|
||||
/// Support RESX lookup via the @Translate SSVE alias
|
||||
type TranslateTokenViewEngineMatcher() =
|
||||
static let regex = Regex ("@Translate\.(?<TranslationKey>[a-zA-Z0-9-_]+);?", RegexOptions.Compiled)
|
||||
interface ISuperSimpleViewEngineMatcher with
|
||||
member this.Invoke (content, model, host) =
|
||||
let translate (m : Match) = Strings.get m.Groups.["TranslationKey"].Value
|
||||
regex.Replace(content, translate)
|
||||
|
||||
|
||||
/// Handle forms authentication
|
||||
type MyWebLogUser (claims : Claim seq) =
|
||||
inherit ClaimsPrincipal (ClaimsIdentity (claims, "forms"))
|
||||
|
||||
new (user : User) =
|
||||
// TODO: refactor the User.Claims property to produce this, and just pass it as the constructor
|
||||
let claims =
|
||||
seq {
|
||||
yield Claim (ClaimTypes.Name, user.PreferredName)
|
||||
for claim in user.Claims -> Claim (ClaimTypes.Role, claim)
|
||||
}
|
||||
MyWebLogUser claims
|
||||
|
||||
type MyWebLogUserMapper (container : TinyIoCContainer) =
|
||||
|
||||
interface IUserMapper with
|
||||
member this.GetUserFromIdentifier (identifier, context) =
|
||||
match context.Request.PersistableSession.GetOrDefault (Keys.User, User.Empty) with
|
||||
| user when user.Id = string identifier -> upcast MyWebLogUser user
|
||||
| _ -> null
|
||||
|
||||
|
||||
/// Set up the application environment
|
||||
type MyWebLogBootstrapper() =
|
||||
inherit DefaultNancyBootstrapper()
|
||||
|
||||
override this.ConfigureRequestContainer (container, context) =
|
||||
base.ConfigureRequestContainer (container, context)
|
||||
/// User mapper for forms authentication
|
||||
container.Register<IUserMapper, MyWebLogUserMapper>()
|
||||
|> ignore
|
||||
|
||||
override this.ConfigureConventions (conventions) =
|
||||
base.ConfigureConventions conventions
|
||||
conventions.StaticContentsConventions.Add
|
||||
(StaticContentConventionBuilder.AddDirectory ("admin/content", "views/admin/content"))
|
||||
// Make theme content available at [theme-name]/
|
||||
Directory.EnumerateDirectories (Path.Combine [| "views"; "themes" |])
|
||||
|> Seq.map (fun themeDir -> themeDir, Path.Combine [| themeDir; "content" |])
|
||||
|> Seq.filter (fun (_, contentDir) -> Directory.Exists contentDir)
|
||||
|> Seq.iter (fun (themeDir, contentDir) ->
|
||||
conventions.StaticContentsConventions.Add
|
||||
(StaticContentConventionBuilder.AddDirectory ((Path.GetFileName themeDir), contentDir)))
|
||||
|
||||
override this.ConfigureApplicationContainer (container) =
|
||||
base.ConfigureApplicationContainer container
|
||||
container.Register<AppConfig> cfg
|
||||
|> ignore
|
||||
data.Force().SetUp ()
|
||||
container.Register<IMyWebLogData> (data.Force ())
|
||||
|> ignore
|
||||
// NodaTime
|
||||
container.Register<IClock> SystemClock.Instance
|
||||
|> ignore
|
||||
// I18N in SSVE
|
||||
container.Register<ISuperSimpleViewEngineMatcher seq> (fun _ _ ->
|
||||
Seq.singleton (TranslateTokenViewEngineMatcher () :> ISuperSimpleViewEngineMatcher))
|
||||
|> ignore
|
||||
|
||||
override this.ApplicationStartup (container, pipelines) =
|
||||
base.ApplicationStartup (container, pipelines)
|
||||
// Forms authentication configuration
|
||||
let auth =
|
||||
FormsAuthenticationConfiguration (
|
||||
CryptographyConfiguration =
|
||||
CryptographyConfiguration (
|
||||
AesEncryptionProvider (PassphraseKeyGenerator (cfg.AuthEncryptionPassphrase, cfg.AuthSalt)),
|
||||
DefaultHmacProvider (PassphraseKeyGenerator (cfg.AuthHmacPassphrase, cfg.AuthSalt))),
|
||||
RedirectUrl = "~/user/log-on",
|
||||
UserMapper = container.Resolve<IUserMapper> ())
|
||||
FormsAuthentication.Enable (pipelines, auth)
|
||||
// CSRF
|
||||
Csrf.Enable pipelines
|
||||
// Sessions
|
||||
let sessions = RethinkDBSessionConfiguration cfg.DataConfig.Conn
|
||||
sessions.Database <- cfg.DataConfig.Database
|
||||
//let sessions = RelationalSessionConfiguration(ConfigurationManager.ConnectionStrings.["SessionStore"].ConnectionString)
|
||||
PersistableSessions.Enable (pipelines, sessions)
|
||||
()
|
||||
|
||||
override this.Configure (environment) =
|
||||
base.Configure environment
|
||||
environment.Tracing (true, true)
|
||||
|
||||
|
||||
let version =
|
||||
let v = typeof<AppConfig>.GetTypeInfo().Assembly.GetName().Version
|
||||
match v.Build with
|
||||
| 0 -> match v.Minor with 0 -> string v.Major | _ -> sprintf "%d.%d" v.Major v.Minor
|
||||
| _ -> sprintf "%d.%d.%d" v.Major v.Minor v.Build
|
||||
|> sprintf "v%s"
|
||||
|
||||
/// Set up the request environment
|
||||
type RequestEnvironment() =
|
||||
interface IRequestStartup with
|
||||
member this.Initialize (pipelines, context) =
|
||||
let establishEnv (ctx : NancyContext) =
|
||||
ctx.Items.[Keys.RequestStart] <- DateTime.Now.Ticks
|
||||
match tryFindWebLogByUrlBase (data.Force ()) ctx.Request.Url.HostName with
|
||||
| Some webLog -> ctx.Items.[Keys.WebLog] <- webLog
|
||||
| None -> // TODO: redirect to domain set up page
|
||||
Exception (sprintf "%s %s" ctx.Request.Url.HostName (Strings.get "ErrNotConfigured"))
|
||||
|> raise
|
||||
ctx.Items.[Keys.Version] <- version
|
||||
null
|
||||
pipelines.BeforeRequest.AddItemToStartOfPipeline establishEnv
|
||||
|
||||
let Run () =
|
||||
OwinApp.ofMidFunc "/" (NancyMiddleware.UseNancy (NancyOptions (Bootstrapper = new MyWebLogBootstrapper ())))
|
||||
|> startWebServer defaultConfig
|
|
@ -1,33 +0,0 @@
|
|||
namespace MyWebLog
|
||||
|
||||
open MyWebLog.Data.RethinkDB
|
||||
open Newtonsoft.Json
|
||||
open System.Text
|
||||
|
||||
/// Configuration for this myWebLog instance
|
||||
type AppConfig =
|
||||
{ /// The text from which to derive salt to use for passwords
|
||||
[<JsonProperty("password-salt")>]
|
||||
PasswordSaltString : string
|
||||
/// The text from which to derive salt to use for forms authentication
|
||||
[<JsonProperty("auth-salt")>]
|
||||
AuthSaltString : string
|
||||
/// The encryption passphrase to use for forms authentication
|
||||
[<JsonProperty("encryption-passphrase")>]
|
||||
AuthEncryptionPassphrase : string
|
||||
/// The HMAC passphrase to use for forms authentication
|
||||
[<JsonProperty("hmac-passphrase")>]
|
||||
AuthHmacPassphrase : string
|
||||
/// The data configuration
|
||||
[<JsonProperty("data")>]
|
||||
DataConfig : DataConfig }
|
||||
with
|
||||
/// The salt to use for passwords
|
||||
member this.PasswordSalt = Encoding.UTF8.GetBytes this.PasswordSaltString
|
||||
/// The salt to use for forms authentication
|
||||
member this.AuthSalt = Encoding.UTF8.GetBytes this.AuthSaltString
|
||||
|
||||
/// Deserialize the configuration from the JSON file
|
||||
static member FromJson json =
|
||||
let cfg = JsonConvert.DeserializeObject<AppConfig> json
|
||||
{ cfg with DataConfig = DataConfig.Connect cfg.DataConfig }
|
|
@ -1,21 +0,0 @@
|
|||
namespace MyWebLog.AssemblyInfo
|
||||
|
||||
open System.Reflection
|
||||
open System.Runtime.CompilerServices
|
||||
open System.Runtime.InteropServices
|
||||
|
||||
[<assembly: AssemblyTitle("MyWebLog.Web")>]
|
||||
[<assembly: AssemblyDescription("Main Nancy assembly for myWebLog")>]
|
||||
[<assembly: AssemblyConfiguration("")>]
|
||||
[<assembly: AssemblyCompany("DJS Consulting")>]
|
||||
[<assembly: AssemblyProduct("MyWebLog.Web")>]
|
||||
[<assembly: AssemblyCopyright("Copyright © 2016")>]
|
||||
[<assembly: AssemblyTrademark("")>]
|
||||
[<assembly: AssemblyCulture("")>]
|
||||
[<assembly: ComVisible(false)>]
|
||||
[<assembly: Guid("e6ee110a-27a6-4a19-b0cb-d24f48f71b53")>]
|
||||
[<assembly: AssemblyVersion("0.9.2.0")>]
|
||||
[<assembly: AssemblyFileVersion("1.0.0.0")>]
|
||||
|
||||
do
|
||||
()
|
|
@ -1,140 +0,0 @@
|
|||
module MyWebLog.Data.RethinkDB.Category
|
||||
|
||||
open MyWebLog.Entities
|
||||
open RethinkDb.Driver.Ast
|
||||
|
||||
let private r = RethinkDb.Driver.RethinkDB.R
|
||||
|
||||
/// Get all categories for a web log
|
||||
let getAllCategories conn (webLogId : string) =
|
||||
async {
|
||||
return! r.Table(Table.Category)
|
||||
.GetAll(webLogId).OptArg("index", "WebLogId")
|
||||
.OrderBy("Name")
|
||||
.RunResultAsync<Category list> conn
|
||||
}
|
||||
|> Async.RunSynchronously
|
||||
|
||||
/// Get a specific category by its Id
|
||||
let tryFindCategory conn webLogId catId : Category option =
|
||||
async {
|
||||
let! c =
|
||||
r.Table(Table.Category)
|
||||
.Get(catId)
|
||||
.RunResultAsync<Category> conn
|
||||
return
|
||||
match box c with
|
||||
| null -> None
|
||||
| catt ->
|
||||
let cat : Category = unbox catt
|
||||
match cat.WebLogId = webLogId with true -> Some cat | _ -> None
|
||||
}
|
||||
|> Async.RunSynchronously
|
||||
|
||||
/// Add a category
|
||||
let addCategory conn (cat : Category) =
|
||||
async {
|
||||
do! r.Table(Table.Category)
|
||||
.Insert(cat)
|
||||
.RunResultAsync conn
|
||||
}
|
||||
|> Async.RunSynchronously
|
||||
|
||||
type CategoryUpdateRecord =
|
||||
{ Name : string
|
||||
Slug : string
|
||||
Description : string option
|
||||
ParentId : string option
|
||||
}
|
||||
/// Update a category
|
||||
let updateCategory conn (cat : Category) =
|
||||
match tryFindCategory conn cat.WebLogId cat.Id with
|
||||
| Some _ ->
|
||||
async {
|
||||
do! r.Table(Table.Category)
|
||||
.Get(cat.Id)
|
||||
.Update(
|
||||
{ CategoryUpdateRecord.Name = cat.Name
|
||||
Slug = cat.Slug
|
||||
Description = cat.Description
|
||||
ParentId = cat.ParentId
|
||||
})
|
||||
.RunResultAsync conn
|
||||
}
|
||||
|> Async.RunSynchronously
|
||||
| _ -> ()
|
||||
|
||||
/// Update a category's children
|
||||
let updateChildren conn webLogId parentId (children : string list) =
|
||||
match tryFindCategory conn webLogId parentId with
|
||||
| Some _ ->
|
||||
async {
|
||||
do! r.Table(Table.Category)
|
||||
.Get(parentId)
|
||||
.Update(dict [ "Children", children ])
|
||||
.RunResultAsync conn
|
||||
}
|
||||
|> Async.RunSynchronously
|
||||
| _ -> ()
|
||||
|
||||
/// Delete a category
|
||||
let deleteCategory conn (cat : Category) =
|
||||
async {
|
||||
// Remove the category from its parent
|
||||
match cat.ParentId with
|
||||
| Some parentId ->
|
||||
match tryFindCategory conn cat.WebLogId parentId with
|
||||
| Some parent -> parent.Children
|
||||
|> List.filter (fun childId -> childId <> cat.Id)
|
||||
|> updateChildren conn cat.WebLogId parentId
|
||||
| _ -> ()
|
||||
| _ -> ()
|
||||
// Move this category's children to its parent
|
||||
cat.Children
|
||||
|> List.map (fun childId ->
|
||||
match tryFindCategory conn cat.WebLogId childId with
|
||||
| Some _ ->
|
||||
async {
|
||||
do! r.Table(Table.Category)
|
||||
.Get(childId)
|
||||
.Update(dict [ "ParentId", cat.ParentId ])
|
||||
.RunResultAsync conn
|
||||
}
|
||||
|> Some
|
||||
| _ -> None)
|
||||
|> List.filter Option.isSome
|
||||
|> List.map Option.get
|
||||
|> List.iter Async.RunSynchronously
|
||||
// Remove the category from posts where it is assigned
|
||||
let! posts =
|
||||
r.Table(Table.Post)
|
||||
.GetAll(cat.WebLogId).OptArg("index", "WebLogId")
|
||||
.Filter(ReqlFunction1 (fun p -> upcast p.["CategoryIds"].Contains cat.Id))
|
||||
.RunResultAsync<Post list> conn
|
||||
|> Async.AwaitTask
|
||||
posts
|
||||
|> List.map (fun post ->
|
||||
async {
|
||||
do! r.Table(Table.Post)
|
||||
.Get(post.Id)
|
||||
.Update(dict [ "CategoryIds", post.CategoryIds |> List.filter (fun c -> c <> cat.Id) ])
|
||||
.RunResultAsync conn
|
||||
})
|
||||
|> List.iter Async.RunSynchronously
|
||||
// Now, delete the category
|
||||
do! r.Table(Table.Category)
|
||||
.Get(cat.Id)
|
||||
.Delete()
|
||||
.RunResultAsync conn
|
||||
}
|
||||
|> Async.RunSynchronously
|
||||
|
||||
/// Get a category by its slug
|
||||
let tryFindCategoryBySlug conn (webLogId : string) (slug : string) =
|
||||
async {
|
||||
let! cat = r.Table(Table.Category)
|
||||
.GetAll(r.Array (webLogId, slug)).OptArg("index", "Slug")
|
||||
.RunResultAsync<Category list> conn
|
||||
return cat |> List.tryHead
|
||||
}
|
||||
|> Async.RunSynchronously
|
|
@ -1,43 +0,0 @@
|
|||
namespace MyWebLog.Data.RethinkDB
|
||||
|
||||
open RethinkDb.Driver
|
||||
open RethinkDb.Driver.Net
|
||||
open Newtonsoft.Json
|
||||
|
||||
/// Data configuration
|
||||
type DataConfig =
|
||||
{ /// The hostname for the RethinkDB server
|
||||
[<JsonProperty("hostname")>]
|
||||
Hostname : string
|
||||
/// The port for the RethinkDB server
|
||||
[<JsonProperty("port")>]
|
||||
Port : int
|
||||
/// The authorization key to use when connecting to the server
|
||||
[<JsonProperty("authKey")>]
|
||||
AuthKey : string
|
||||
/// How long an attempt to connect to the server should wait before giving up
|
||||
[<JsonProperty("timeout")>]
|
||||
Timeout : int
|
||||
/// The name of the default database to use on the connection
|
||||
[<JsonProperty("database")>]
|
||||
Database : string
|
||||
/// A connection to the RethinkDB server using the configuration in this object
|
||||
[<JsonIgnore>]
|
||||
Conn : IConnection }
|
||||
with
|
||||
/// Use RethinkDB defaults for non-provided options, and connect to the server
|
||||
static member Connect config =
|
||||
let host cfg = match cfg.Hostname with null -> { cfg with Hostname = RethinkDBConstants.DefaultHostname } | _ -> cfg
|
||||
let port cfg = match cfg.Port with 0 -> { cfg with Port = RethinkDBConstants.DefaultPort } | _ -> cfg
|
||||
let auth cfg = match cfg.AuthKey with null -> { cfg with AuthKey = RethinkDBConstants.DefaultAuthkey } | _ -> cfg
|
||||
let timeout cfg = match cfg.Timeout with 0 -> { cfg with Timeout = RethinkDBConstants.DefaultTimeout } | _ -> cfg
|
||||
let db cfg = match cfg.Database with null -> { cfg with Database = RethinkDBConstants.DefaultDbName } | _ -> cfg
|
||||
let connect cfg =
|
||||
{ cfg with Conn = RethinkDB.R.Connection()
|
||||
.Hostname(cfg.Hostname)
|
||||
.Port(cfg.Port)
|
||||
.AuthKey(cfg.AuthKey)
|
||||
.Db(cfg.Database)
|
||||
.Timeout(cfg.Timeout)
|
||||
.Connect () }
|
||||
(host >> port >> auth >> timeout >> db >> connect) config
|
|
@ -1,16 +0,0 @@
|
|||
[<AutoOpen>]
|
||||
module MyWebLog.Data.RethinkDB.Extensions
|
||||
|
||||
open System.Threading.Tasks
|
||||
|
||||
// H/T: Suave
|
||||
type AsyncBuilder with
|
||||
/// An extension method that overloads the standard 'Bind' of the 'async' builder. The new overload awaits on
|
||||
/// a standard .NET task
|
||||
member x.Bind(t : Task<'T>, f:'T -> Async<'R>) : Async<'R> = async.Bind(Async.AwaitTask t, f)
|
||||
|
||||
/// An extension method that overloads the standard 'Bind' of the 'async' builder. The new overload awaits on
|
||||
/// a standard .NET task which does not commpute a value
|
||||
member x.Bind(t : Task, f : unit -> Async<'R>) : Async<'R> = async.Bind(Async.AwaitTask t, f)
|
||||
|
||||
member x.ReturnFrom(t : Task<'T>) = Async.AwaitTask t
|
|
@ -1,98 +0,0 @@
|
|||
module MyWebLog.Data.RethinkDB.Page
|
||||
|
||||
open MyWebLog.Entities
|
||||
open RethinkDb.Driver.Ast
|
||||
|
||||
let private r = RethinkDb.Driver.RethinkDB.R
|
||||
|
||||
/// Try to find a page by its Id, optionally including revisions
|
||||
let tryFindPageById conn webLogId (pageId : string) includeRevs =
|
||||
async {
|
||||
let q =
|
||||
r.Table(Table.Page)
|
||||
.Get pageId
|
||||
let! thePage =
|
||||
match includeRevs with
|
||||
| true -> q.RunResultAsync<Page> conn
|
||||
| _ -> q.Without("Revisions").RunResultAsync<Page> conn
|
||||
return
|
||||
match box thePage with
|
||||
| null -> None
|
||||
| page ->
|
||||
let pg : Page = unbox page
|
||||
match pg.WebLogId = webLogId with true -> Some pg | _ -> None
|
||||
}
|
||||
|> Async.RunSynchronously
|
||||
|
||||
/// Find a page by its permalink
|
||||
let tryFindPageByPermalink conn (webLogId : string) (permalink : string) =
|
||||
async {
|
||||
let! pg =
|
||||
r.Table(Table.Page)
|
||||
.GetAll(r.Array (webLogId, permalink)).OptArg("index", "Permalink")
|
||||
.Without("Revisions")
|
||||
.RunResultAsync<Page list> conn
|
||||
return List.tryHead pg
|
||||
}
|
||||
|> Async.RunSynchronously
|
||||
|
||||
/// Get a list of all pages (excludes page text and revisions)
|
||||
let findAllPages conn (webLogId : string) =
|
||||
async {
|
||||
return!
|
||||
r.Table(Table.Page)
|
||||
.GetAll(webLogId).OptArg("index", "WebLogId")
|
||||
.OrderBy("Title")
|
||||
.Without("Text", "Revisions")
|
||||
.RunResultAsync<Page list> conn
|
||||
}
|
||||
|> Async.RunSynchronously
|
||||
|
||||
/// Add a page
|
||||
let addPage conn (page : Page) =
|
||||
async {
|
||||
do! r.Table(Table.Page)
|
||||
.Insert(page)
|
||||
.RunResultAsync conn
|
||||
}
|
||||
|> (Async.RunSynchronously >> ignore)
|
||||
|
||||
type PageUpdateRecord =
|
||||
{ Title : string
|
||||
Permalink : string
|
||||
PublishedOn : int64
|
||||
UpdatedOn : int64
|
||||
ShowInPageList : bool
|
||||
Text : string
|
||||
Revisions : Revision list }
|
||||
/// Update a page
|
||||
let updatePage conn (page : Page) =
|
||||
match tryFindPageById conn page.WebLogId page.Id false with
|
||||
| Some _ ->
|
||||
async {
|
||||
do! r.Table(Table.Page)
|
||||
.Get(page.Id)
|
||||
.Update({ PageUpdateRecord.Title = page.Title
|
||||
Permalink = page.Permalink
|
||||
PublishedOn = page.PublishedOn
|
||||
UpdatedOn = page.UpdatedOn
|
||||
ShowInPageList = page.ShowInPageList
|
||||
Text = page.Text
|
||||
Revisions = page.Revisions })
|
||||
.RunResultAsync conn
|
||||
}
|
||||
|> (Async.RunSynchronously >> ignore)
|
||||
| _ -> ()
|
||||
|
||||
/// Delete a page
|
||||
let deletePage conn webLogId pageId =
|
||||
match tryFindPageById conn webLogId pageId false with
|
||||
| Some _ ->
|
||||
async {
|
||||
do! r.Table(Table.Page)
|
||||
.Get(pageId)
|
||||
.Delete()
|
||||
.RunResultAsync conn
|
||||
}
|
||||
|> (Async.RunSynchronously >> ignore)
|
||||
| _ -> ()
|
|
@ -1,225 +0,0 @@
|
|||
module MyWebLog.Data.RethinkDB.Post
|
||||
|
||||
open MyWebLog.Entities
|
||||
open RethinkDb.Driver.Ast
|
||||
|
||||
let private r = RethinkDb.Driver.RethinkDB.R
|
||||
|
||||
/// Shorthand to select all published posts for a web log
|
||||
let private publishedPosts (webLogId : string) =
|
||||
r.Table(Table.Post)
|
||||
.GetAll(r.Array (webLogId, PostStatus.Published)).OptArg("index", "WebLogAndStatus")
|
||||
.Without("Revisions")
|
||||
// This allows us to count comments without retrieving them all
|
||||
.Merge(ReqlFunction1 (fun p ->
|
||||
upcast r.HashMap(
|
||||
"Comments", r.Table(Table.Comment)
|
||||
.GetAll(p.["id"]).OptArg("index", "PostId")
|
||||
.Pluck("id")
|
||||
.CoerceTo("array"))))
|
||||
|
||||
|
||||
/// Shorthand to sort posts by published date, slice for the given page, and return a list
|
||||
let private toPostList conn pageNbr nbrPerPage (filter : ReqlExpr) =
|
||||
async {
|
||||
return!
|
||||
filter
|
||||
.OrderBy(r.Desc "PublishedOn")
|
||||
.Slice((pageNbr - 1) * nbrPerPage, pageNbr * nbrPerPage)
|
||||
.RunResultAsync<Post list> conn
|
||||
}
|
||||
|> Async.RunSynchronously
|
||||
|
||||
/// Shorthand to get a newer or older post
|
||||
let private adjacentPost conn (post : Post) (theFilter : ReqlExpr -> obj) (sort : obj) =
|
||||
async {
|
||||
let! post =
|
||||
(publishedPosts post.WebLogId)
|
||||
.Filter(theFilter)
|
||||
.OrderBy(sort)
|
||||
.Limit(1)
|
||||
.RunResultAsync<Post list> conn
|
||||
return List.tryHead post
|
||||
}
|
||||
|> Async.RunSynchronously
|
||||
|
||||
/// Find a newer post
|
||||
let private newerPost conn post theFilter = adjacentPost conn post theFilter <| r.Asc "PublishedOn"
|
||||
|
||||
/// Find an older post
|
||||
let private olderPost conn post theFilter = adjacentPost conn post theFilter <| r.Desc "PublishedOn"
|
||||
|
||||
/// Get a page of published posts
|
||||
let findPageOfPublishedPosts conn webLogId pageNbr nbrPerPage =
|
||||
publishedPosts webLogId
|
||||
|> toPostList conn pageNbr nbrPerPage
|
||||
|
||||
/// Get a page of published posts assigned to a given category
|
||||
let findPageOfCategorizedPosts conn webLogId (categoryId : string) pageNbr nbrPerPage =
|
||||
(publishedPosts webLogId)
|
||||
.Filter(ReqlFunction1 (fun p -> upcast p.["CategoryIds"].Contains categoryId))
|
||||
|> toPostList conn pageNbr nbrPerPage
|
||||
|
||||
/// Get a page of published posts tagged with a given tag
|
||||
let findPageOfTaggedPosts conn webLogId (tag : string) pageNbr nbrPerPage =
|
||||
(publishedPosts webLogId)
|
||||
.Filter(ReqlFunction1 (fun p -> upcast p.["Tags"].Contains tag))
|
||||
|> toPostList conn pageNbr nbrPerPage
|
||||
|
||||
/// Try to get the next newest post from the given post
|
||||
let tryFindNewerPost conn post = newerPost conn post (fun p -> upcast p.["PublishedOn"].Gt post.PublishedOn)
|
||||
|
||||
/// Try to get the next newest post assigned to the given category
|
||||
let tryFindNewerCategorizedPost conn (categoryId : string) post =
|
||||
newerPost conn post (fun p -> upcast p.["PublishedOn"].Gt(post.PublishedOn)
|
||||
.And(p.["CategoryIds"].Contains categoryId))
|
||||
|
||||
/// Try to get the next newest tagged post from the given tagged post
|
||||
let tryFindNewerTaggedPost conn (tag : string) post =
|
||||
newerPost conn post (fun p -> upcast p.["PublishedOn"].Gt(post.PublishedOn).And(p.["Tags"].Contains tag))
|
||||
|
||||
/// Try to get the next oldest post from the given post
|
||||
let tryFindOlderPost conn post = olderPost conn post (fun p -> upcast p.["PublishedOn"].Lt post.PublishedOn)
|
||||
|
||||
/// Try to get the next oldest post assigned to the given category
|
||||
let tryFindOlderCategorizedPost conn (categoryId : string) post =
|
||||
olderPost conn post (fun p -> upcast p.["PublishedOn"].Lt(post.PublishedOn)
|
||||
.And(p.["CategoryIds"].Contains categoryId))
|
||||
|
||||
/// Try to get the next oldest tagged post from the given tagged post
|
||||
let tryFindOlderTaggedPost conn (tag : string) post =
|
||||
olderPost conn post (fun p -> upcast p.["PublishedOn"].Lt(post.PublishedOn).And(p.["Tags"].Contains tag))
|
||||
|
||||
/// Get a page of all posts in all statuses
|
||||
let findPageOfAllPosts conn (webLogId : string) pageNbr nbrPerPage =
|
||||
// FIXME: sort unpublished posts by their last updated date
|
||||
async {
|
||||
// .orderBy(r.desc(r.branch(r.row("Status").eq("Published"), r.row("PublishedOn"), r.row("UpdatedOn"))))
|
||||
return!
|
||||
r.Table(Table.Post)
|
||||
.GetAll(webLogId).OptArg("index", "WebLogId")
|
||||
.OrderBy(r.Desc (ReqlFunction1 (fun p ->
|
||||
upcast r.Branch (p.["Status"].Eq("Published"), p.["PublishedOn"], p.["UpdatedOn"]))))
|
||||
.Slice((pageNbr - 1) * nbrPerPage, pageNbr * nbrPerPage)
|
||||
.RunResultAsync<Post list> conn
|
||||
}
|
||||
|> Async.RunSynchronously
|
||||
|
||||
/// Try to find a post by its Id and web log Id
|
||||
let tryFindPost conn webLogId postId : Post option =
|
||||
async {
|
||||
let! p =
|
||||
r.Table(Table.Post)
|
||||
.Get(postId)
|
||||
.RunAtomAsync<Post> conn
|
||||
return
|
||||
match box p with
|
||||
| null -> None
|
||||
| pst ->
|
||||
let post : Post = unbox pst
|
||||
match post.WebLogId = webLogId with true -> Some post | _ -> None
|
||||
}
|
||||
|> Async.RunSynchronously
|
||||
|
||||
/// Try to find a post by its permalink
|
||||
let tryFindPostByPermalink conn webLogId permalink =
|
||||
async {
|
||||
let! post =
|
||||
r.Table(Table.Post)
|
||||
.GetAll(r.Array (webLogId, permalink)).OptArg("index", "Permalink")
|
||||
.Filter(ReqlFunction1 (fun p -> upcast p.["Status"].Eq PostStatus.Published))
|
||||
.Without("Revisions")
|
||||
.Merge(ReqlFunction1 (fun p ->
|
||||
upcast r.HashMap(
|
||||
"Categories", r.Table(Table.Category)
|
||||
.GetAll(r.Args p.["CategoryIds"])
|
||||
.Without("Children")
|
||||
.OrderBy("Name")
|
||||
.CoerceTo("array")).With(
|
||||
"Comments", r.Table(Table.Comment)
|
||||
.GetAll(p.["id"]).OptArg("index", "PostId")
|
||||
.OrderBy("PostedOn")
|
||||
.CoerceTo("array"))))
|
||||
.RunResultAsync<Post list> conn
|
||||
return List.tryHead post
|
||||
}
|
||||
|> Async.RunSynchronously
|
||||
|
||||
/// Try to find a post by its prior permalink
|
||||
let tryFindPostByPriorPermalink conn (webLogId : string) (permalink : string) =
|
||||
async {
|
||||
let! post =
|
||||
r.Table(Table.Post)
|
||||
.GetAll(webLogId).OptArg("index", "WebLogId")
|
||||
.Filter(ReqlFunction1 (fun p ->
|
||||
upcast p.["PriorPermalinks"].Contains(permalink).And(p.["Status"].Eq PostStatus.Published)))
|
||||
.Without("Revisions")
|
||||
.RunResultAsync<Post list> conn
|
||||
return List.tryHead post
|
||||
}
|
||||
|> Async.RunSynchronously
|
||||
|
||||
/// Get a set of posts for RSS
|
||||
let findFeedPosts conn webLogId nbr : (Post * User option) list =
|
||||
let tryFindUser userId =
|
||||
async {
|
||||
let! u =
|
||||
r.Table(Table.User)
|
||||
.Get(userId)
|
||||
.RunAtomAsync<User> conn
|
||||
return match box u with null -> None | user -> Some <| unbox user
|
||||
}
|
||||
|> Async.RunSynchronously
|
||||
(publishedPosts webLogId)
|
||||
.Merge(ReqlFunction1 (fun post ->
|
||||
upcast r.HashMap(
|
||||
"Categories", r.Table(Table.Category)
|
||||
.GetAll(r.Args post.["CategoryIds"])
|
||||
.OrderBy("Name")
|
||||
.Pluck("id", "Name")
|
||||
.CoerceTo("array"))))
|
||||
|> toPostList conn 1 nbr
|
||||
|> List.map (fun post -> post, tryFindUser post.AuthorId)
|
||||
|
||||
/// Add a post
|
||||
let addPost conn post =
|
||||
async {
|
||||
do! r.Table(Table.Post)
|
||||
.Insert(post)
|
||||
.RunResultAsync conn
|
||||
}
|
||||
|> (Async.RunSynchronously >> ignore)
|
||||
|
||||
/// Update a post
|
||||
let updatePost conn (post : Post) =
|
||||
async {
|
||||
do! r.Table(Table.Post)
|
||||
.Get(post.Id)
|
||||
.Replace( { post with Categories = []
|
||||
Comments = [] } )
|
||||
.RunResultAsync conn
|
||||
}
|
||||
|> (Async.RunSynchronously >> ignore)
|
||||
|
||||
/// Save a post
|
||||
let savePost conn (post : Post) =
|
||||
match post.Id with
|
||||
| "new" ->
|
||||
let newPost = { post with Id = string <| System.Guid.NewGuid() }
|
||||
async {
|
||||
do! r.Table(Table.Post)
|
||||
.Insert(newPost)
|
||||
.RunResultAsync conn
|
||||
}
|
||||
|> Async.RunSynchronously
|
||||
newPost.Id
|
||||
| _ ->
|
||||
async {
|
||||
do! r.Table(Table.Post)
|
||||
.Get(post.Id)
|
||||
.Replace( { post with Categories = []
|
||||
Comments = [] } )
|
||||
.RunResultAsync conn
|
||||
}
|
||||
|> Async.RunSynchronously
|
||||
post.Id
|
|
@ -1,48 +0,0 @@
|
|||
namespace MyWebLog.Data.RethinkDB
|
||||
|
||||
open MyWebLog.Data
|
||||
open RethinkDb.Driver.Net
|
||||
|
||||
/// RethinkDB implementation of myWebLog data persistence
|
||||
type RethinkMyWebLogData(conn : IConnection, cfg : DataConfig) =
|
||||
interface IMyWebLogData with
|
||||
member __.SetUp = fun () -> SetUp.startUpCheck cfg
|
||||
|
||||
member __.AllCategories = Category.getAllCategories conn
|
||||
member __.CategoryById = Category.tryFindCategory conn
|
||||
member __.CategoryBySlug = Category.tryFindCategoryBySlug conn
|
||||
member __.AddCategory = Category.addCategory conn
|
||||
member __.UpdateCategory = Category.updateCategory conn
|
||||
member __.UpdateChildren = Category.updateChildren conn
|
||||
member __.DeleteCategory = Category.deleteCategory conn
|
||||
|
||||
member __.PageById = Page.tryFindPageById conn
|
||||
member __.PageByPermalink = Page.tryFindPageByPermalink conn
|
||||
member __.AllPages = Page.findAllPages conn
|
||||
member __.AddPage = Page.addPage conn
|
||||
member __.UpdatePage = Page.updatePage conn
|
||||
member __.DeletePage = Page.deletePage conn
|
||||
|
||||
member __.PageOfPublishedPosts = Post.findPageOfPublishedPosts conn
|
||||
member __.PageOfCategorizedPosts = Post.findPageOfCategorizedPosts conn
|
||||
member __.PageOfTaggedPosts = Post.findPageOfTaggedPosts conn
|
||||
member __.NewerPost = Post.tryFindNewerPost conn
|
||||
member __.NewerCategorizedPost = Post.tryFindNewerCategorizedPost conn
|
||||
member __.NewerTaggedPost = Post.tryFindNewerTaggedPost conn
|
||||
member __.OlderPost = Post.tryFindOlderPost conn
|
||||
member __.OlderCategorizedPost = Post.tryFindOlderCategorizedPost conn
|
||||
member __.OlderTaggedPost = Post.tryFindOlderTaggedPost conn
|
||||
member __.PageOfAllPosts = Post.findPageOfAllPosts conn
|
||||
member __.PostById = Post.tryFindPost conn
|
||||
member __.PostByPermalink = Post.tryFindPostByPermalink conn
|
||||
member __.PostByPriorPermalink = Post.tryFindPostByPriorPermalink conn
|
||||
member __.FeedPosts = Post.findFeedPosts conn
|
||||
member __.AddPost = Post.addPost conn
|
||||
member __.UpdatePost = Post.updatePost conn
|
||||
|
||||
member __.LogOn = User.tryUserLogOn conn
|
||||
member __.SetUserPassword = User.setUserPassword conn
|
||||
|
||||
member __.WebLogByUrlBase = WebLog.tryFindWebLogByUrlBase conn
|
||||
member __.DashboardCounts = WebLog.findDashboardCounts conn
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
module MyWebLog.Data.RethinkDB.SetUp
|
||||
|
||||
open RethinkDb.Driver.Ast
|
||||
open System
|
||||
|
||||
let private r = RethinkDb.Driver.RethinkDB.R
|
||||
let private logStep step = Console.Out.WriteLine (sprintf "[myWebLog] %s" step)
|
||||
let private logStepStart text = Console.Out.Write (sprintf "[myWebLog] %s..." text)
|
||||
let private logStepDone () = Console.Out.WriteLine (" done.")
|
||||
|
||||
/// Ensure the myWebLog database exists
|
||||
let private checkDatabase (cfg : DataConfig) =
|
||||
async {
|
||||
logStep "|> Checking database"
|
||||
let! dbs = r.DbList().RunResultAsync<string list> cfg.Conn
|
||||
match List.contains cfg.Database dbs with
|
||||
| true -> ()
|
||||
| _ -> logStepStart (sprintf " %s database not found - creating" cfg.Database)
|
||||
do! r.DbCreate(cfg.Database).RunResultAsync cfg.Conn
|
||||
logStepDone ()
|
||||
}
|
||||
|
||||
|
||||
/// Ensure all required tables exist
|
||||
let private checkTables cfg =
|
||||
async {
|
||||
logStep "|> Checking tables"
|
||||
let! tables = r.Db(cfg.Database).TableList().RunResultAsync<string list> cfg.Conn
|
||||
[ Table.Category; Table.Comment; Table.Page; Table.Post; Table.User; Table.WebLog ]
|
||||
|> List.filter (fun tbl -> not (List.contains tbl tables))
|
||||
|> List.iter (fun tbl -> logStepStart (sprintf " Creating table %s" tbl)
|
||||
async { do! (r.TableCreate tbl).RunResultAsync cfg.Conn } |> Async.RunSynchronously
|
||||
logStepDone ())
|
||||
}
|
||||
|
||||
/// Shorthand to get the table
|
||||
let private tbl cfg table = r.Db(cfg.Database).Table table
|
||||
|
||||
/// Create the given index
|
||||
let private createIndex cfg table (index : string * (ReqlExpr -> obj) option) =
|
||||
async {
|
||||
let idxName, idxFunc = index
|
||||
logStepStart (sprintf """ Creating index "%s" on table %s""" idxName table)
|
||||
do! (match idxFunc with
|
||||
| Some f -> (tbl cfg table).IndexCreate(idxName, f)
|
||||
| None -> (tbl cfg table).IndexCreate(idxName))
|
||||
.RunResultAsync cfg.Conn
|
||||
logStepDone ()
|
||||
}
|
||||
|
||||
/// Ensure that the given indexes exist, and create them if required
|
||||
let private ensureIndexes cfg (indexes : (string * (string * (ReqlExpr -> obj) option) list) list) =
|
||||
let ensureForTable (tblName, idxs) =
|
||||
async {
|
||||
let! idx = (tbl cfg tblName).IndexList().RunResultAsync<string list> cfg.Conn
|
||||
idxs
|
||||
|> List.filter (fun (idxName, _) -> not (List.contains idxName idx))
|
||||
|> List.map (fun index -> createIndex cfg tblName index)
|
||||
|> List.iter Async.RunSynchronously
|
||||
}
|
||||
|> Async.RunSynchronously
|
||||
indexes
|
||||
|> List.iter ensureForTable
|
||||
|
||||
/// Create an index on web log Id and the given field
|
||||
let private webLogField (name : string) : (ReqlExpr -> obj) option =
|
||||
Some <| fun row -> upcast r.Array(row.["WebLogId"], row.[name])
|
||||
|
||||
/// Ensure all the required indexes exist
|
||||
let private checkIndexes cfg =
|
||||
logStep "|> Checking indexes"
|
||||
[ Table.Category, [ "WebLogId", None
|
||||
"Slug", webLogField "Slug"
|
||||
]
|
||||
Table.Comment, [ "PostId", None
|
||||
]
|
||||
Table.Page, [ "WebLogId", None
|
||||
"Permalink", webLogField "Permalink"
|
||||
]
|
||||
Table.Post, [ "WebLogId", None
|
||||
"WebLogAndStatus", webLogField "Status"
|
||||
"Permalink", webLogField "Permalink"
|
||||
]
|
||||
Table.User, [ "UserName", None
|
||||
]
|
||||
Table.WebLog, [ "UrlBase", None
|
||||
]
|
||||
]
|
||||
|> ensureIndexes cfg
|
||||
|
||||
/// Start up checks to ensure the database, tables, and indexes exist
|
||||
let startUpCheck cfg =
|
||||
async {
|
||||
logStep "Database Start Up Checks Starting"
|
||||
do! checkDatabase cfg
|
||||
do! checkTables cfg
|
||||
checkIndexes cfg
|
||||
logStep "Database Start Up Checks Complete"
|
||||
}
|
||||
|> Async.RunSynchronously
|
|
@ -1,21 +0,0 @@
|
|||
/// Constants for tables used in myWebLog
|
||||
[<RequireQualifiedAccess>]
|
||||
module MyWebLog.Data.RethinkDB.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 WebLog table
|
||||
let WebLog = "WebLog"
|
||||
|
||||
/// The User table
|
||||
let User = "User"
|
|
@ -1,31 +0,0 @@
|
|||
module MyWebLog.Data.RethinkDB.User
|
||||
|
||||
open MyWebLog.Entities
|
||||
open RethinkDb.Driver.Ast
|
||||
|
||||
let private r = RethinkDb.Driver.RethinkDB.R
|
||||
|
||||
/// Log on a user
|
||||
// NOTE: The significant length of a RethinkDB index is 238 - [PK size]; as we're storing 1,024 characters of password,
|
||||
// including it in an index does not get any performance gain, and would unnecessarily bloat the index. See
|
||||
// http://rethinkdb.com/docs/secondary-indexes/java/ for more information.
|
||||
let tryUserLogOn conn (email : string) (passwordHash : string) =
|
||||
async {
|
||||
let! user =
|
||||
r.Table(Table.User)
|
||||
.GetAll(email).OptArg("index", "UserName")
|
||||
.Filter(ReqlFunction1 (fun u -> upcast u.["PasswordHash"].Eq passwordHash))
|
||||
.RunResultAsync<User list> conn
|
||||
return user |> List.tryHead
|
||||
}
|
||||
|> Async.RunSynchronously
|
||||
|
||||
/// Set a user's password
|
||||
let setUserPassword conn (email : string) (passwordHash : string) =
|
||||
async {
|
||||
do! r.Table(Table.User)
|
||||
.GetAll(email).OptArg("index", "UserName")
|
||||
.Update(dict [ "PasswordHash", passwordHash ])
|
||||
.RunResultAsync conn
|
||||
}
|
||||
|> Async.RunSynchronously
|
|
@ -1,39 +0,0 @@
|
|||
module MyWebLog.Data.RethinkDB.WebLog
|
||||
|
||||
open MyWebLog.Entities
|
||||
open RethinkDb.Driver.Ast
|
||||
|
||||
let private r = RethinkDb.Driver.RethinkDB.R
|
||||
|
||||
/// Detemine the web log by the URL base
|
||||
let tryFindWebLogByUrlBase conn (urlBase : string) =
|
||||
async {
|
||||
let! cursor =
|
||||
r.Table(Table.WebLog)
|
||||
.GetAll(urlBase).OptArg("index", "UrlBase")
|
||||
.Merge(ReqlFunction1 (fun w ->
|
||||
upcast r.HashMap(
|
||||
"PageList", r.Table(Table.Page)
|
||||
.GetAll(w.G("id")).OptArg("index", "WebLogId")
|
||||
.Filter(ReqlFunction1 (fun pg -> upcast pg.["ShowInPageList"].Eq true))
|
||||
.OrderBy("Title")
|
||||
.Pluck("Title", "Permalink")
|
||||
.CoerceTo("array"))))
|
||||
.RunCursorAsync<WebLog> conn
|
||||
return cursor |> Seq.tryHead
|
||||
}
|
||||
|> Async.RunSynchronously
|
||||
|
||||
/// Get counts for the admin dashboard
|
||||
let findDashboardCounts conn (webLogId : string) =
|
||||
async {
|
||||
return!
|
||||
r.Expr(
|
||||
r.HashMap(
|
||||
"Pages", r.Table(Table.Page ).GetAll(webLogId).OptArg("index", "WebLogId").Count()).With(
|
||||
"Posts", r.Table(Table.Post ).GetAll(webLogId).OptArg("index", "WebLogId").Count()).With(
|
||||
"Categories", r.Table(Table.Category).GetAll(webLogId).OptArg("index", "WebLogId").Count()))
|
||||
.RunResultAsync<DashboardCounts> conn
|
||||
}
|
||||
|> Async.RunSynchronously
|
||||
|
|
@ -1,301 +0,0 @@
|
|||
namespace MyWebLog.Entities
|
||||
|
||||
open Newtonsoft.Json
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
/// Constants to use for revision source language
|
||||
[<RequireQualifiedAccess>]
|
||||
module RevisionSource =
|
||||
[<Literal>]
|
||||
let Markdown = "markdown"
|
||||
[<Literal>]
|
||||
let HTML = "html"
|
||||
|
||||
/// Constants to use for authorization levels
|
||||
[<RequireQualifiedAccess>]
|
||||
module AuthorizationLevel =
|
||||
[<Literal>]
|
||||
let Administrator = "Administrator"
|
||||
[<Literal>]
|
||||
let User = "User"
|
||||
|
||||
/// Constants to use for post statuses
|
||||
[<RequireQualifiedAccess>]
|
||||
module PostStatus =
|
||||
[<Literal>]
|
||||
let Draft = "Draft"
|
||||
[<Literal>]
|
||||
let Published = "Published"
|
||||
|
||||
/// Constants to use for comment statuses
|
||||
[<RequireQualifiedAccess>]
|
||||
module CommentStatus =
|
||||
[<Literal>]
|
||||
let Approved = "Approved"
|
||||
[<Literal>]
|
||||
let Pending = "Pending"
|
||||
[<Literal>]
|
||||
let Spam = "Spam"
|
||||
|
||||
// --- Entities ---
|
||||
|
||||
/// A revision of a post or page
|
||||
type Revision =
|
||||
{ /// The instant which this revision was saved
|
||||
AsOf : int64
|
||||
/// The source language
|
||||
SourceType : string
|
||||
/// The text
|
||||
Text : string }
|
||||
with
|
||||
/// An empty revision
|
||||
static member Empty =
|
||||
{ AsOf = int64 0
|
||||
SourceType = RevisionSource.HTML
|
||||
Text = "" }
|
||||
|
||||
/// A page with static content
|
||||
type Page =
|
||||
{ /// The Id
|
||||
[<JsonProperty("id")>]
|
||||
Id : string
|
||||
/// The Id of the web log to which this page belongs
|
||||
WebLogId : string
|
||||
/// The Id of the author of this page
|
||||
AuthorId : string
|
||||
/// The title of the page
|
||||
Title : string
|
||||
/// The link at which this page is displayed
|
||||
Permalink : string
|
||||
/// The instant this page was published
|
||||
PublishedOn : int64
|
||||
/// The instant this page was last updated
|
||||
UpdatedOn : int64
|
||||
/// Whether this page shows as part of the web log's navigation
|
||||
ShowInPageList : bool
|
||||
/// The current text of the page
|
||||
Text : string
|
||||
/// Revisions of this page
|
||||
Revisions : Revision list }
|
||||
with
|
||||
static member Empty =
|
||||
{ Id = ""
|
||||
WebLogId = ""
|
||||
AuthorId = ""
|
||||
Title = ""
|
||||
Permalink = ""
|
||||
PublishedOn = int64 0
|
||||
UpdatedOn = int64 0
|
||||
ShowInPageList = false
|
||||
Text = ""
|
||||
Revisions = []
|
||||
}
|
||||
|
||||
|
||||
/// An entry in the list of pages displayed as part of the web log (derived via query)
|
||||
type PageListEntry =
|
||||
{ Permalink : string
|
||||
Title : string }
|
||||
|
||||
/// A web log
|
||||
type WebLog =
|
||||
{ /// The Id
|
||||
[<JsonProperty("id")>]
|
||||
Id : string
|
||||
/// 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 = ""
|
||||
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 : string
|
||||
/// The level of access granted by this authorization
|
||||
Level : string }
|
||||
|
||||
|
||||
/// A user of myWebLog
|
||||
type User =
|
||||
{ /// The Id
|
||||
[<JsonProperty("id")>]
|
||||
Id : string
|
||||
/// 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 = ""
|
||||
UserName = ""
|
||||
FirstName = ""
|
||||
LastName = ""
|
||||
PreferredName = ""
|
||||
PasswordHash = ""
|
||||
Url = None
|
||||
Authorizations = [] }
|
||||
|
||||
/// Claims for this user
|
||||
[<JsonIgnore>]
|
||||
member this.Claims = this.Authorizations
|
||||
|> List.map (fun auth -> sprintf "%s|%s" auth.WebLogId auth.Level)
|
||||
|
||||
|
||||
/// A category to which posts may be assigned
|
||||
type Category =
|
||||
{ /// The Id
|
||||
[<JsonProperty("id")>]
|
||||
Id : string
|
||||
/// The Id of the web log to which this category belongs
|
||||
WebLogId : string
|
||||
/// 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 : string option
|
||||
/// The categories for which this category is the parent
|
||||
Children : string list }
|
||||
with
|
||||
/// An empty category
|
||||
static member Empty =
|
||||
{ Id = "new"
|
||||
WebLogId = ""
|
||||
Name = ""
|
||||
Slug = ""
|
||||
Description = None
|
||||
ParentId = None
|
||||
Children = [] }
|
||||
|
||||
|
||||
/// A comment (applies to a post)
|
||||
type Comment =
|
||||
{ /// The Id
|
||||
[<JsonProperty("id")>]
|
||||
Id : string
|
||||
/// The Id of the post to which this comment applies
|
||||
PostId : string
|
||||
/// The Id of the comment to which this comment is a reply
|
||||
InReplyToId : string 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 : string
|
||||
/// The instant the comment was posted
|
||||
PostedOn : int64
|
||||
/// The text of the comment
|
||||
Text : string }
|
||||
with
|
||||
static member Empty =
|
||||
{ Id = ""
|
||||
PostId = ""
|
||||
InReplyToId = None
|
||||
Name = ""
|
||||
Email = ""
|
||||
Url = None
|
||||
Status = CommentStatus.Pending
|
||||
PostedOn = int64 0
|
||||
Text = "" }
|
||||
|
||||
|
||||
/// A post
|
||||
type Post =
|
||||
{ /// The Id
|
||||
[<JsonProperty("id")>]
|
||||
Id : string
|
||||
/// The Id of the web log to which this post belongs
|
||||
WebLogId : string
|
||||
/// The Id of the author of this post
|
||||
AuthorId : string
|
||||
/// The status
|
||||
Status : string
|
||||
/// The title
|
||||
Title : string
|
||||
/// The link at which the post resides
|
||||
Permalink : string
|
||||
/// The instant on which the post was originally published
|
||||
PublishedOn : int64
|
||||
/// The instant on which the post was last updated
|
||||
UpdatedOn : int64
|
||||
/// The text of the post
|
||||
Text : string
|
||||
/// The Ids of the categories to which this is assigned
|
||||
CategoryIds : string 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 = "new"
|
||||
WebLogId = ""
|
||||
AuthorId = ""
|
||||
Status = PostStatus.Draft
|
||||
Title = ""
|
||||
Permalink = ""
|
||||
PublishedOn = int64 0
|
||||
UpdatedOn = int64 0
|
||||
Text = ""
|
||||
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 }
|
|
@ -1,117 +0,0 @@
|
|||
namespace MyWebLog.Data
|
||||
|
||||
open MyWebLog.Entities
|
||||
|
||||
/// Interface required to provide data to myWebLog's logic layer
|
||||
type IMyWebLogData =
|
||||
/// Function to set up the data store
|
||||
abstract SetUp : (unit -> unit)
|
||||
|
||||
// --- Category ---
|
||||
|
||||
/// Get all categories for a web log
|
||||
abstract AllCategories : (string -> Category list)
|
||||
|
||||
/// Try to find a category by its Id and web log Id (web log, category Ids)
|
||||
abstract CategoryById : (string -> string -> Category option)
|
||||
|
||||
/// Try to find a category by its slug (web log Id, slug)
|
||||
abstract CategoryBySlug : (string -> string -> Category option)
|
||||
|
||||
/// Add a category
|
||||
abstract AddCategory : (Category -> unit)
|
||||
|
||||
/// Update a category
|
||||
abstract UpdateCategory : (Category -> unit)
|
||||
|
||||
/// Update a category's children
|
||||
abstract UpdateChildren : (string -> string -> string list -> unit)
|
||||
|
||||
/// Delete a Category
|
||||
abstract DeleteCategory : (Category -> unit)
|
||||
|
||||
// --- Page ---
|
||||
|
||||
/// Try to find a page by its Id and web log Id (web log, page Ids), choosing whether to include revisions
|
||||
abstract PageById : (string -> string -> bool -> Page option)
|
||||
|
||||
/// Try to find a page by its permalink and web log Id (web log Id, permalink)
|
||||
abstract PageByPermalink : (string -> string -> Page option)
|
||||
|
||||
/// Get all pages for a web log
|
||||
abstract AllPages : (string -> Page list)
|
||||
|
||||
/// Add a page
|
||||
abstract AddPage : (Page -> unit)
|
||||
|
||||
/// Update a page
|
||||
abstract UpdatePage : (Page -> unit)
|
||||
|
||||
/// Delete a page by its Id and web log Id (web log, page Ids)
|
||||
abstract DeletePage : (string -> string -> unit)
|
||||
|
||||
// --- Post ---
|
||||
|
||||
/// Find a page of published posts for the given web log (web log Id, page #, # per page)
|
||||
abstract PageOfPublishedPosts : (string -> int -> int -> Post list)
|
||||
|
||||
/// Find a page of published posts within a given category (web log Id, cat Id, page #, # per page)
|
||||
abstract PageOfCategorizedPosts : (string -> string -> int -> int -> Post list)
|
||||
|
||||
/// Find a page of published posts tagged with a given tag (web log Id, tag, page #, # per page)
|
||||
abstract PageOfTaggedPosts : (string -> string -> int -> int -> Post list)
|
||||
|
||||
/// Try to find the next newer published post for the given post
|
||||
abstract NewerPost : (Post -> Post option)
|
||||
|
||||
/// Try to find the next newer published post within a given category
|
||||
abstract NewerCategorizedPost : (string -> Post -> Post option)
|
||||
|
||||
/// Try to find the next newer published post tagged with a given tag
|
||||
abstract NewerTaggedPost : (string -> Post -> Post option)
|
||||
|
||||
/// Try to find the next older published post for the given post
|
||||
abstract OlderPost : (Post -> Post option)
|
||||
|
||||
/// Try to find the next older published post within a given category
|
||||
abstract OlderCategorizedPost : (string -> Post -> Post option)
|
||||
|
||||
/// Try to find the next older published post tagged with a given tag
|
||||
abstract OlderTaggedPost : (string -> Post -> Post option)
|
||||
|
||||
/// Find a page of all posts for the given web log (web log Id, page #, # per page)
|
||||
abstract PageOfAllPosts : (string -> int -> int -> Post list)
|
||||
|
||||
/// Try to find a post by its Id and web log Id (web log, post Ids)
|
||||
abstract PostById : (string -> string -> Post option)
|
||||
|
||||
/// Try to find a post by its permalink (web log Id, permalink)
|
||||
abstract PostByPermalink : (string -> string -> Post option)
|
||||
|
||||
/// Try to find a post by a prior permalink (web log Id, permalink)
|
||||
abstract PostByPriorPermalink : (string -> string -> Post option)
|
||||
|
||||
/// Get posts for the RSS feed for the given web log and number of posts
|
||||
abstract FeedPosts : (string -> int -> (Post * User option) list)
|
||||
|
||||
/// Add a post
|
||||
abstract AddPost : (Post -> unit)
|
||||
|
||||
/// Update a post
|
||||
abstract UpdatePost : (Post -> unit)
|
||||
|
||||
// --- User ---
|
||||
|
||||
/// Attempt to log on a user
|
||||
abstract LogOn : (string -> string -> User option)
|
||||
|
||||
/// Set a user's password (e-mail, password hash)
|
||||
abstract SetUserPassword : (string -> string -> unit)
|
||||
|
||||
// --- WebLog ---
|
||||
|
||||
/// Get a web log by its URL base
|
||||
abstract WebLogByUrlBase : (string -> WebLog option)
|
||||
|
||||
/// Get dashboard counts for a web log
|
||||
abstract DashboardCounts : (string -> DashboardCounts)
|
|
@ -1,17 +0,0 @@
|
|||
[<RequireQualifiedAccess>]
|
||||
module MyWebLog.Keys
|
||||
|
||||
/// Messages stored in the session
|
||||
let Messages = "messages"
|
||||
|
||||
/// The request start time (stored in the context for each request)
|
||||
let RequestStart = "request-start"
|
||||
|
||||
/// The current user
|
||||
let User = "user"
|
||||
|
||||
/// The version of myWebLog
|
||||
let Version = "version"
|
||||
|
||||
/// The web log
|
||||
let WebLog = "web-log"
|
|
@ -1,56 +0,0 @@
|
|||
module MyWebLog.Logic.Category
|
||||
|
||||
open MyWebLog.Data
|
||||
open MyWebLog.Entities
|
||||
|
||||
/// Sort categories by their name, with their children sorted below them, including an indent level
|
||||
let sortCategories categories =
|
||||
let rec getChildren (cat : Category) indent =
|
||||
seq {
|
||||
yield cat, indent
|
||||
for child in categories |> List.filter (fun c -> c.ParentId = Some cat.Id) do
|
||||
yield! getChildren child (indent + 1)
|
||||
}
|
||||
categories
|
||||
|> List.filter (fun c -> c.ParentId.IsNone)
|
||||
|> List.map (fun c -> getChildren c 0)
|
||||
|> Seq.collect id
|
||||
|> Seq.toList
|
||||
|
||||
/// Find all categories for a given web log
|
||||
let findAllCategories (data : IMyWebLogData) webLogId =
|
||||
data.AllCategories webLogId
|
||||
|> sortCategories
|
||||
|
||||
/// Try to find a category for a given web log Id and category Id
|
||||
let tryFindCategory (data : IMyWebLogData) webLogId catId = data.CategoryById webLogId catId
|
||||
|
||||
/// Try to find a category by its slug for a given web log
|
||||
let tryFindCategoryBySlug (data : IMyWebLogData) webLogId slug = data.CategoryBySlug webLogId slug
|
||||
|
||||
/// Save a category
|
||||
let saveCategory (data : IMyWebLogData) (cat : Category) =
|
||||
match cat.Id with
|
||||
| "new" -> let newCat = { cat with Id = string <| System.Guid.NewGuid() }
|
||||
data.AddCategory newCat
|
||||
newCat.Id
|
||||
| _ -> data.UpdateCategory cat
|
||||
cat.Id
|
||||
|
||||
/// Remove a category from its parent
|
||||
let removeCategoryFromParent (data : IMyWebLogData) webLogId parentId catId =
|
||||
match tryFindCategory data webLogId parentId with
|
||||
| Some parent -> parent.Children
|
||||
|> List.filter (fun childId -> childId <> catId)
|
||||
|> data.UpdateChildren webLogId parentId
|
||||
| None -> ()
|
||||
|
||||
/// Add a category to a given parent
|
||||
let addCategoryToParent (data : IMyWebLogData) webLogId parentId catId =
|
||||
match tryFindCategory data webLogId parentId with
|
||||
| Some parent -> catId :: parent.Children
|
||||
|> data.UpdateChildren webLogId parentId
|
||||
| None -> ()
|
||||
|
||||
/// Delete a category
|
||||
let deleteCategory (data : IMyWebLogData) cat = data.DeleteCategory cat
|
|
@ -1,29 +0,0 @@
|
|||
/// Logic for manipulating <see cref="Page" /> entities
|
||||
module MyWebLog.Logic.Page
|
||||
|
||||
open MyWebLog.Data
|
||||
open MyWebLog.Entities
|
||||
|
||||
/// Find a page by its Id and web log Id
|
||||
let tryFindPage (data : IMyWebLogData) webLogId pageId = data.PageById webLogId pageId true
|
||||
|
||||
/// Find a page by its Id and web log Id, without the revision list
|
||||
let tryFindPageWithoutRevisions (data : IMyWebLogData) webLogId pageId = data.PageById webLogId pageId false
|
||||
|
||||
/// Find a page by its permalink
|
||||
let tryFindPageByPermalink (data : IMyWebLogData) webLogId permalink = data.PageByPermalink webLogId permalink
|
||||
|
||||
/// Find a list of all pages (excludes text and revisions)
|
||||
let findAllPages (data : IMyWebLogData) webLogId = data.AllPages webLogId
|
||||
|
||||
/// Save a page
|
||||
let savePage (data : IMyWebLogData) (page : Page) =
|
||||
match page.Id with
|
||||
| "new" -> let newPg = { page with Id = string <| System.Guid.NewGuid () }
|
||||
data.AddPage newPg
|
||||
newPg.Id
|
||||
| _ -> data.UpdatePage page
|
||||
page.Id
|
||||
|
||||
/// Delete a page
|
||||
let deletePage (data : IMyWebLogData) webLogId pageId = data.DeletePage webLogId pageId
|
|
@ -1,60 +0,0 @@
|
|||
/// Logic for manipulating <see cref="Post" /> entities
|
||||
module MyWebLog.Logic.Post
|
||||
|
||||
open MyWebLog.Data
|
||||
open MyWebLog.Entities
|
||||
|
||||
/// Find a page of published posts
|
||||
let findPageOfPublishedPosts (data : IMyWebLogData) webLogId pageNbr nbrPerPage =
|
||||
data.PageOfPublishedPosts webLogId pageNbr nbrPerPage
|
||||
|
||||
/// Find a pages of published posts in a given category
|
||||
let findPageOfCategorizedPosts (data : IMyWebLogData) webLogId catId pageNbr nbrPerPage =
|
||||
data.PageOfCategorizedPosts webLogId catId pageNbr nbrPerPage
|
||||
|
||||
/// Find a page of published posts tagged with a given tag
|
||||
let findPageOfTaggedPosts (data : IMyWebLogData) webLogId tag pageNbr nbrPerPage =
|
||||
data.PageOfTaggedPosts webLogId tag pageNbr nbrPerPage
|
||||
|
||||
/// Find the next newer published post for the given post
|
||||
let tryFindNewerPost (data : IMyWebLogData) post = data.NewerPost post
|
||||
|
||||
/// Find the next newer published post in a given category for the given post
|
||||
let tryFindNewerCategorizedPost (data : IMyWebLogData) catId post = data.NewerCategorizedPost catId post
|
||||
|
||||
/// Find the next newer published post tagged with a given tag for the given post
|
||||
let tryFindNewerTaggedPost (data : IMyWebLogData) tag post = data.NewerTaggedPost tag post
|
||||
|
||||
/// Find the next older published post for the given post
|
||||
let tryFindOlderPost (data : IMyWebLogData) post = data.OlderPost post
|
||||
|
||||
/// Find the next older published post in a given category for the given post
|
||||
let tryFindOlderCategorizedPost (data : IMyWebLogData) catId post = data.OlderCategorizedPost catId post
|
||||
|
||||
/// Find the next older published post tagged with a given tag for the given post
|
||||
let tryFindOlderTaggedPost (data : IMyWebLogData) tag post = data.OlderTaggedPost tag post
|
||||
|
||||
/// Find a page of all posts for a web log
|
||||
let findPageOfAllPosts (data : IMyWebLogData) webLogId pageNbr nbrPerPage =
|
||||
data.PageOfAllPosts webLogId pageNbr nbrPerPage
|
||||
|
||||
/// Try to find a post by its Id
|
||||
let tryFindPost (data : IMyWebLogData) webLogId postId = data.PostById webLogId postId
|
||||
|
||||
/// Try to find a post by its permalink
|
||||
let tryFindPostByPermalink (data : IMyWebLogData) webLogId permalink = data.PostByPermalink webLogId permalink
|
||||
|
||||
/// Try to find a post by its prior permalink
|
||||
let tryFindPostByPriorPermalink (data : IMyWebLogData) webLogId permalink = data.PostByPriorPermalink webLogId permalink
|
||||
|
||||
/// Find posts for the RSS feed
|
||||
let findFeedPosts (data : IMyWebLogData) webLogId nbrOfPosts = data.FeedPosts webLogId nbrOfPosts
|
||||
|
||||
/// Save a post
|
||||
let savePost (data : IMyWebLogData) (post : Post) =
|
||||
match post.Id with
|
||||
| "new" -> let newPost = { post with Id = string <| System.Guid.NewGuid() }
|
||||
data.AddPost newPost
|
||||
newPost.Id
|
||||
| _ -> data.UpdatePost post
|
||||
post.Id
|
|
@ -1,9 +0,0 @@
|
|||
/// Logic for manipulating <see cref="User" /> entities
|
||||
module MyWebLog.Logic.User
|
||||
|
||||
open MyWebLog.Data
|
||||
|
||||
/// Try to log on a user
|
||||
let tryUserLogOn (data : IMyWebLogData) email passwordHash = data.LogOn email passwordHash
|
||||
|
||||
let setUserPassword (data : IMyWebLogData) = data.SetUserPassword
|
|
@ -1,11 +0,0 @@
|
|||
/// Logic for manipulating <see cref="WebLog" /> entities
|
||||
module MyWebLog.Logic.WebLog
|
||||
|
||||
open MyWebLog.Data
|
||||
open MyWebLog.Entities
|
||||
|
||||
/// Find a web log by its URL base
|
||||
let tryFindWebLogByUrlBase (data : IMyWebLogData) urlBase = data.WebLogByUrlBase urlBase
|
||||
|
||||
/// Find the counts for the admin dashboard
|
||||
let findDashboardCounts (data : IMyWebLogData) webLogId = data.DashboardCounts webLogId
|
|
@ -1,22 +0,0 @@
|
|||
namespace MyWebLog
|
||||
|
||||
open MyWebLog.Data
|
||||
open MyWebLog.Entities
|
||||
open MyWebLog.Logic.WebLog
|
||||
open MyWebLog.Resources
|
||||
open Nancy
|
||||
open RethinkDb.Driver.Net
|
||||
|
||||
/// Handle /admin routes
|
||||
type AdminModule (data : IMyWebLogData) as this =
|
||||
inherit NancyModule ("/admin")
|
||||
|
||||
do
|
||||
this.Get ("/", fun _ -> this.Dashboard ())
|
||||
|
||||
/// Admin dashboard
|
||||
member this.Dashboard () : obj =
|
||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
||||
let model = DashboardModel (this.Context, this.WebLog, findDashboardCounts data this.WebLog.Id)
|
||||
model.PageTitle <- Strings.get "Dashboard"
|
||||
upcast this.View.["admin/dashboard", model]
|
|
@ -1,97 +0,0 @@
|
|||
namespace MyWebLog
|
||||
|
||||
open MyWebLog.Data
|
||||
open MyWebLog.Logic.Category
|
||||
open MyWebLog.Entities
|
||||
open MyWebLog.Resources
|
||||
open Nancy
|
||||
open Nancy.ModelBinding
|
||||
open Nancy.Security
|
||||
open RethinkDb.Driver.Net
|
||||
|
||||
/// Handle /category and /categories URLs
|
||||
type CategoryModule (data : IMyWebLogData) as this =
|
||||
inherit NancyModule ()
|
||||
|
||||
do
|
||||
this.Get ("/categories", fun _ -> this.CategoryList ())
|
||||
this.Get ("/category/{id}/edit", fun p -> this.EditCategory (downcast p))
|
||||
this.Post ("/category/{id}/edit", fun p -> this.SaveCategory (downcast p))
|
||||
this.Post ("/category/{id}/delete", fun p -> this.DeleteCategory (downcast p))
|
||||
|
||||
/// Display a list of categories
|
||||
member this.CategoryList () : obj =
|
||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
||||
let model =
|
||||
CategoryListModel (
|
||||
this.Context, this.WebLog, findAllCategories data this.WebLog.Id
|
||||
|> List.map (fun cat -> IndentedCategory.Create cat (fun _ -> false)))
|
||||
model.PageTitle <- Strings.get "Categories"
|
||||
upcast this.View.["admin/category/list", model]
|
||||
|
||||
/// Edit a category
|
||||
member this.EditCategory (parameters : DynamicDictionary) : obj =
|
||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
||||
let catId = parameters.["id"].ToString ()
|
||||
match catId with "new" -> Some Category.Empty | _ -> tryFindCategory data this.WebLog.Id catId
|
||||
|> function
|
||||
| Some cat ->
|
||||
let model = CategoryEditModel (this.Context, this.WebLog, cat)
|
||||
model.Categories <- findAllCategories data this.WebLog.Id
|
||||
|> List.map (fun c ->
|
||||
IndentedCategory.Create c (fun catId -> catId = defaultArg cat.ParentId ""))
|
||||
model.PageTitle <- Strings.get <| match catId with "new" -> "AddNewCategory" | _ -> "EditCategory"
|
||||
upcast this.View.["admin/category/edit", model]
|
||||
| _ -> this.NotFound ()
|
||||
|
||||
/// Save a category
|
||||
member this.SaveCategory (parameters : DynamicDictionary) : obj =
|
||||
this.ValidateCsrfToken ()
|
||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
||||
let catId = parameters.["id"].ToString ()
|
||||
let form = this.Bind<CategoryForm> ()
|
||||
match catId with
|
||||
| "new" -> Some { Category.Empty with WebLogId = this.WebLog.Id }
|
||||
| _ -> tryFindCategory data this.WebLog.Id catId
|
||||
|> function
|
||||
| Some old ->
|
||||
let cat =
|
||||
{ old with
|
||||
Name = form.Name
|
||||
Slug = form.Slug
|
||||
Description = match form.Description with "" -> None | d -> Some d
|
||||
ParentId = match form.ParentId with "" -> None | p -> Some p
|
||||
}
|
||||
let newCatId = saveCategory data cat
|
||||
match old.ParentId = cat.ParentId with
|
||||
| true -> ()
|
||||
| _ ->
|
||||
match old.ParentId with
|
||||
| Some parentId -> removeCategoryFromParent data this.WebLog.Id parentId newCatId
|
||||
| _ -> ()
|
||||
match cat.ParentId with
|
||||
| Some parentId -> addCategoryToParent data this.WebLog.Id parentId newCatId
|
||||
| _ -> ()
|
||||
let model = MyWebLogModel (this.Context, this.WebLog)
|
||||
model.AddMessage
|
||||
{ UserMessage.Empty with
|
||||
Message = System.String.Format
|
||||
(Strings.get "MsgCategoryEditSuccess",
|
||||
Strings.get (match catId with "new" -> "Added" | _ -> "Updated"))
|
||||
}
|
||||
this.Redirect (sprintf "/category/%s/edit" newCatId) model
|
||||
| _ -> this.NotFound ()
|
||||
|
||||
/// Delete a category
|
||||
member this.DeleteCategory (parameters : DynamicDictionary) : obj =
|
||||
this.ValidateCsrfToken ()
|
||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
||||
let catId = parameters.["id"].ToString ()
|
||||
match tryFindCategory data this.WebLog.Id catId with
|
||||
| Some cat ->
|
||||
deleteCategory data cat
|
||||
let model = MyWebLogModel (this.Context, this.WebLog)
|
||||
model.AddMessage
|
||||
{ UserMessage.Empty with Message = System.String.Format(Strings.get "MsgCategoryDeleted", cat.Name) }
|
||||
this.Redirect "/categories" model
|
||||
| _ -> this.NotFound ()
|
|
@ -1,36 +0,0 @@
|
|||
[<AutoOpen>]
|
||||
module MyWebLog.ModuleExtensions
|
||||
|
||||
open MyWebLog.Entities
|
||||
open Nancy
|
||||
open Nancy.Security
|
||||
open System
|
||||
open System.Security.Claims
|
||||
|
||||
/// Parent class for all myWebLog Nancy modules
|
||||
type NancyModule with
|
||||
|
||||
/// Strongly-typed access to the web log for the current request
|
||||
member this.WebLog = this.Context.Items.[Keys.WebLog] :?> WebLog
|
||||
|
||||
/// Display a view using the theme specified for the web log
|
||||
member this.ThemedView view (model : MyWebLogModel) : obj =
|
||||
upcast this.View.[(sprintf "themes/%s/%s" this.WebLog.ThemePath view), model]
|
||||
|
||||
/// Return a 404
|
||||
member this.NotFound () : obj = upcast HttpStatusCode.NotFound
|
||||
|
||||
/// Redirect a request, storing messages in the session if they exist
|
||||
member this.Redirect url (model : MyWebLogModel) : obj =
|
||||
match List.length model.Messages with
|
||||
| 0 -> ()
|
||||
| _ -> this.Session.[Keys.Messages] <- model.Messages
|
||||
// Temp (307) redirects don't reset the HTTP method; this allows POST-process-REDIRECT workflow
|
||||
upcast this.Response.AsRedirect(url).WithStatusCode HttpStatusCode.MovedPermanently
|
||||
|
||||
/// Require a specific level of access for the current web log
|
||||
member this.RequiresAccessLevel level =
|
||||
let findClaim = Predicate<Claim> (fun claim ->
|
||||
claim.Type = ClaimTypes.Role && claim.Value = sprintf "%s|%s" this.WebLog.Id level)
|
||||
this.RequiresAuthentication ()
|
||||
this.RequiresClaims [| findClaim |]
|
|
@ -1,97 +0,0 @@
|
|||
namespace MyWebLog
|
||||
|
||||
open MyWebLog.Data
|
||||
open MyWebLog.Entities
|
||||
open MyWebLog.Logic.Page
|
||||
open MyWebLog.Resources
|
||||
open Nancy
|
||||
open Nancy.ModelBinding
|
||||
open Nancy.Security
|
||||
open NodaTime
|
||||
open RethinkDb.Driver.Net
|
||||
|
||||
/// Handle /pages and /page URLs
|
||||
type PageModule (data : IMyWebLogData, clock : IClock) as this =
|
||||
inherit NancyModule ()
|
||||
|
||||
do
|
||||
this.Get ("/pages", fun _ -> this.PageList ())
|
||||
this.Get ("/page/{id}/edit", fun p -> this.EditPage (downcast p))
|
||||
this.Post ("/page/{id}/edit", fun p -> this.SavePage (downcast p))
|
||||
this.Delete ("/page/{id}/delete", fun p -> this.DeletePage (downcast p))
|
||||
|
||||
/// List all pages
|
||||
member this.PageList () : obj =
|
||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
||||
let model =
|
||||
PagesModel (this.Context, this.WebLog, findAllPages data this.WebLog.Id
|
||||
|> List.map (fun p -> PageForDisplay (this.WebLog, p)))
|
||||
model.PageTitle <- Strings.get "Pages"
|
||||
upcast this.View.["admin/page/list", model]
|
||||
|
||||
/// Edit a page
|
||||
member this.EditPage (parameters : DynamicDictionary) : obj =
|
||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
||||
let pageId = parameters.["id"].ToString ()
|
||||
match pageId with "new" -> Some Page.Empty | _ -> tryFindPage data this.WebLog.Id pageId
|
||||
|> function
|
||||
| Some page ->
|
||||
let rev = match page.Revisions
|
||||
|> List.sortByDescending (fun r -> r.AsOf)
|
||||
|> List.tryHead with
|
||||
| Some r -> r
|
||||
| _ -> Revision.Empty
|
||||
let model = EditPageModel (this.Context, this.WebLog, page, rev)
|
||||
model.PageTitle <- Strings.get <| match pageId with "new" -> "AddNewPage" | _ -> "EditPage"
|
||||
upcast this.View.["admin/page/edit", model]
|
||||
| _ -> this.NotFound ()
|
||||
|
||||
/// Save a page
|
||||
member this.SavePage (parameters : DynamicDictionary) : obj =
|
||||
this.ValidateCsrfToken ()
|
||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
||||
let pageId = parameters.["id"].ToString ()
|
||||
let form = this.Bind<EditPageForm> ()
|
||||
let now = clock.GetCurrentInstant().ToUnixTimeTicks ()
|
||||
match pageId with "new" -> Some Page.Empty | _ -> tryFindPage data this.WebLog.Id pageId
|
||||
|> function
|
||||
| Some p ->
|
||||
let page = match pageId with "new" -> { p with WebLogId = this.WebLog.Id } | _ -> p
|
||||
let pId =
|
||||
{ p with
|
||||
Title = form.Title
|
||||
Permalink = form.Permalink
|
||||
PublishedOn = match pageId with "new" -> now | _ -> page.PublishedOn
|
||||
UpdatedOn = now
|
||||
ShowInPageList = form.ShowInPageList
|
||||
Text = match form.Source with
|
||||
| RevisionSource.Markdown -> (* Markdown.TransformHtml *) form.Text
|
||||
| _ -> form.Text
|
||||
Revisions = { AsOf = now
|
||||
SourceType = form.Source
|
||||
Text = form.Text
|
||||
} :: page.Revisions
|
||||
}
|
||||
|> savePage data
|
||||
let model = MyWebLogModel (this.Context, this.WebLog)
|
||||
model.AddMessage
|
||||
{ UserMessage.Empty with
|
||||
Message = System.String.Format
|
||||
(Strings.get "MsgPageEditSuccess",
|
||||
Strings.get (match pageId with "new" -> "Added" | _ -> "Updated"))
|
||||
}
|
||||
this.Redirect (sprintf "/page/%s/edit" pId) model
|
||||
| _ -> this.NotFound ()
|
||||
|
||||
/// Delete a page
|
||||
member this.DeletePage (parameters : DynamicDictionary) : obj =
|
||||
this.ValidateCsrfToken ()
|
||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
||||
let pageId = parameters.["id"].ToString ()
|
||||
match tryFindPageWithoutRevisions data this.WebLog.Id pageId with
|
||||
| Some page ->
|
||||
deletePage data page.WebLogId page.Id
|
||||
let model = MyWebLogModel (this.Context, this.WebLog)
|
||||
model.AddMessage { UserMessage.Empty with Message = Strings.get "MsgPageDeleted" }
|
||||
this.Redirect "/pages" model
|
||||
| _ -> this.NotFound ()
|
|
@ -1,317 +0,0 @@
|
|||
namespace MyWebLog
|
||||
|
||||
open MyWebLog.Data
|
||||
open MyWebLog.Entities
|
||||
open MyWebLog.Logic.Category
|
||||
open MyWebLog.Logic.Page
|
||||
open MyWebLog.Logic.Post
|
||||
open MyWebLog.Resources
|
||||
open Nancy
|
||||
open Nancy.ModelBinding
|
||||
open Nancy.Security
|
||||
open Nancy.Session.Persistable
|
||||
open NodaTime
|
||||
open RethinkDb.Driver.Net
|
||||
open System
|
||||
open System.Xml.Linq
|
||||
|
||||
type NewsItem =
|
||||
{ Title : string
|
||||
Link : string
|
||||
ReleaseDate : DateTime
|
||||
Description : string
|
||||
}
|
||||
|
||||
/// Routes dealing with posts (including the home page, /tag, /category, RSS, and catch-all routes)
|
||||
type PostModule (data : IMyWebLogData, clock : IClock) as this =
|
||||
inherit NancyModule ()
|
||||
|
||||
/// Get the page number from the dictionary
|
||||
let getPage (parameters : DynamicDictionary) =
|
||||
match parameters.ContainsKey "page" with
|
||||
| true -> match System.Int32.TryParse (parameters.["page"].ToString ()) with true, pg -> pg | _ -> 1
|
||||
| _ -> 1
|
||||
|
||||
/// Convert a list of posts to a list of posts for display
|
||||
let forDisplay posts = posts |> List.map (fun post -> PostForDisplay (this.WebLog, post))
|
||||
|
||||
/// Generate an RSS/Atom feed of the latest posts
|
||||
let generateFeed format : obj =
|
||||
let myChannelFeed channelTitle channelLink channelDescription (items : NewsItem list) =
|
||||
let xn = XName.Get
|
||||
let elem name (valu : string) = XElement (xn name, valu)
|
||||
let elems =
|
||||
items
|
||||
|> List.sortByDescending (fun i -> i.ReleaseDate)
|
||||
|> List.map (fun i ->
|
||||
XElement (
|
||||
xn "item",
|
||||
elem "title" (System.Net.WebUtility.HtmlEncode i.Title),
|
||||
elem "link" i.Link,
|
||||
elem "guid" i.Link,
|
||||
elem "pubDate" (i.ReleaseDate.ToString "r"),
|
||||
elem "description" (System.Net.WebUtility.HtmlEncode i.Description)
|
||||
))
|
||||
XDocument (
|
||||
XDeclaration ("1.0", "utf-8", "yes"),
|
||||
XElement (
|
||||
xn "rss",
|
||||
XAttribute (xn "version", "2.0"),
|
||||
elem "title" channelTitle,
|
||||
elem "link" channelLink,
|
||||
elem "description" (defaultArg channelDescription ""),
|
||||
elem "language" "en-us",
|
||||
XElement (xn "channel", elems))
|
||||
|> box)
|
||||
let schemeAndUrl = sprintf "%s://%s" this.Request.Url.Scheme this.WebLog.UrlBase
|
||||
let feed =
|
||||
findFeedPosts data this.WebLog.Id 10
|
||||
|> List.map (fun (post, _) ->
|
||||
{ Title = post.Title
|
||||
Link = sprintf "%s/%s" schemeAndUrl post.Permalink
|
||||
ReleaseDate = Instant.FromUnixTimeTicks(post.PublishedOn).ToDateTimeOffset().DateTime
|
||||
Description = post.Text
|
||||
})
|
||||
|> myChannelFeed this.WebLog.Name schemeAndUrl this.WebLog.Subtitle
|
||||
let stream = new IO.MemoryStream ()
|
||||
Xml.XmlWriter.Create stream |> feed.Save
|
||||
//|> match format with "atom" -> feed.SaveAsAtom10 | _ -> feed.SaveAsRss20
|
||||
stream.Position <- 0L
|
||||
upcast this.Response.FromStream (stream, sprintf "application/%s+xml" format)
|
||||
// TODO: how to return this?
|
||||
|
||||
(*
|
||||
let feed =
|
||||
SyndicationFeed(
|
||||
this.WebLog.Name, defaultArg this.WebLog.Subtitle null,
|
||||
Uri(sprintf "%s://%s" this.Request.Url.Scheme this.WebLog.UrlBase), null,
|
||||
(match posts |> List.tryHead with
|
||||
| Some (post, _) -> Instant(post.UpdatedOn).ToDateTimeOffset ()
|
||||
| _ -> System.DateTimeOffset(System.DateTime.MinValue)),
|
||||
posts
|
||||
|> List.map (fun (post, user) ->
|
||||
let item =
|
||||
SyndicationItem(
|
||||
BaseUri = Uri(sprintf "%s://%s/%s" this.Request.Url.Scheme this.WebLog.UrlBase post.Permalink),
|
||||
PublishDate = Instant(post.PublishedOn).ToDateTimeOffset (),
|
||||
LastUpdatedTime = Instant(post.UpdatedOn).ToDateTimeOffset (),
|
||||
Title = TextSyndicationContent(post.Title),
|
||||
Content = TextSyndicationContent(post.Text, TextSyndicationContentKind.Html))
|
||||
user
|
||||
|> Option.iter (fun u -> item.Authors.Add
|
||||
(SyndicationPerson(u.UserName, u.PreferredName, defaultArg u.Url null)))
|
||||
post.Categories
|
||||
|> List.iter (fun c -> item.Categories.Add(SyndicationCategory(c.Name)))
|
||||
item))
|
||||
let stream = new IO.MemoryStream()
|
||||
Xml.XmlWriter.Create(stream)
|
||||
|> match format with "atom" -> feed.SaveAsAtom10 | _ -> feed.SaveAsRss20
|
||||
stream.Position <- int64 0
|
||||
upcast this.Response.FromStream(stream, sprintf "application/%s+xml" format) *)
|
||||
|
||||
do
|
||||
this.Get ("/", fun _ -> this.HomePage ())
|
||||
this.Get ("/{permalink*}", fun p -> this.CatchAll (downcast p))
|
||||
this.Get ("/posts/page/{page:int}", fun p -> this.PublishedPostsPage (getPage <| downcast p))
|
||||
this.Get ("/category/{slug}", fun p -> this.CategorizedPosts (downcast p))
|
||||
this.Get ("/category/{slug}/page/{page:int}", fun p -> this.CategorizedPosts (downcast p))
|
||||
this.Get ("/tag/{tag}", fun p -> this.TaggedPosts (downcast p))
|
||||
this.Get ("/tag/{tag}/page/{page:int}", fun p -> this.TaggedPosts (downcast p))
|
||||
this.Get ("/feed", fun _ -> this.Feed ())
|
||||
this.Get ("/posts/list", fun _ -> this.PostList 1)
|
||||
this.Get ("/posts/list/page/{page:int}", fun p -> this.PostList (getPage <| downcast p))
|
||||
this.Get ("/post/{postId}/edit", fun p -> this.EditPost (downcast p))
|
||||
this.Post ("/post/{postId}/edit", fun p -> this.SavePost (downcast p))
|
||||
|
||||
// ---- Display posts to users ----
|
||||
|
||||
/// Display a page of published posts
|
||||
member this.PublishedPostsPage pageNbr : obj =
|
||||
let model = PostsModel (this.Context, this.WebLog)
|
||||
model.PageNbr <- pageNbr
|
||||
model.Posts <- findPageOfPublishedPosts data this.WebLog.Id pageNbr 10 |> forDisplay
|
||||
model.HasNewer <- match pageNbr with
|
||||
| 1 -> false
|
||||
| _ -> match List.isEmpty model.Posts with
|
||||
| true -> false
|
||||
| _ -> Option.isSome <| tryFindNewerPost data (List.last model.Posts).Post
|
||||
model.HasOlder <- match List.isEmpty model.Posts with
|
||||
| true -> false
|
||||
| _ -> Option.isSome <| tryFindOlderPost data (List.head model.Posts).Post
|
||||
model.UrlPrefix <- "/posts"
|
||||
model.PageTitle <- match pageNbr with 1 -> "" | _ -> sprintf "%s%i" (Strings.get "PageHash") pageNbr
|
||||
this.ThemedView "index" model
|
||||
|
||||
/// Display either the newest posts or the configured home page
|
||||
member this.HomePage () : obj =
|
||||
match this.WebLog.DefaultPage with
|
||||
| "posts" -> this.PublishedPostsPage 1
|
||||
| pageId ->
|
||||
match tryFindPageWithoutRevisions data this.WebLog.Id pageId with
|
||||
| Some page ->
|
||||
let model = PageModel(this.Context, this.WebLog, page)
|
||||
model.PageTitle <- page.Title
|
||||
this.ThemedView "page" model
|
||||
| _ -> this.NotFound ()
|
||||
|
||||
/// Derive a post or page from the URL, or redirect from a prior URL to the current one
|
||||
member this.CatchAll (parameters : DynamicDictionary) : obj =
|
||||
let url = parameters.["permalink"].ToString ()
|
||||
match tryFindPostByPermalink data this.WebLog.Id url with
|
||||
| Some post -> // Hopefully the most common result; the permalink is a permalink!
|
||||
let model = PostModel(this.Context, this.WebLog, post)
|
||||
model.NewerPost <- tryFindNewerPost data post
|
||||
model.OlderPost <- tryFindOlderPost data post
|
||||
model.PageTitle <- post.Title
|
||||
this.ThemedView "single" model
|
||||
| _ -> // Maybe it's a page permalink instead...
|
||||
match tryFindPageByPermalink data this.WebLog.Id url with
|
||||
| Some page -> // ...and it is!
|
||||
let model = PageModel (this.Context, this.WebLog, page)
|
||||
model.PageTitle <- page.Title
|
||||
this.ThemedView "page" model
|
||||
| _ -> // Maybe it's an old permalink for a post
|
||||
match tryFindPostByPriorPermalink data this.WebLog.Id url with
|
||||
| Some post -> // Redirect them to the proper permalink
|
||||
upcast this.Response.AsRedirect(sprintf "/%s" post.Permalink)
|
||||
.WithStatusCode HttpStatusCode.MovedPermanently
|
||||
| _ -> this.NotFound ()
|
||||
|
||||
/// Display categorized posts
|
||||
member this.CategorizedPosts (parameters : DynamicDictionary) : obj =
|
||||
let slug = parameters.["slug"].ToString ()
|
||||
match tryFindCategoryBySlug data this.WebLog.Id slug with
|
||||
| Some cat ->
|
||||
let pageNbr = getPage parameters
|
||||
let model = PostsModel (this.Context, this.WebLog)
|
||||
model.PageNbr <- pageNbr
|
||||
model.Posts <- findPageOfCategorizedPosts data this.WebLog.Id cat.Id pageNbr 10 |> forDisplay
|
||||
model.HasNewer <- match List.isEmpty model.Posts with
|
||||
| true -> false
|
||||
| _ -> Option.isSome <| tryFindNewerCategorizedPost data cat.Id
|
||||
(List.head model.Posts).Post
|
||||
model.HasOlder <- match List.isEmpty model.Posts with
|
||||
| true -> false
|
||||
| _ -> Option.isSome <| tryFindOlderCategorizedPost data cat.Id
|
||||
(List.last model.Posts).Post
|
||||
model.UrlPrefix <- sprintf "/category/%s" slug
|
||||
model.PageTitle <- sprintf "\"%s\" Category%s" cat.Name
|
||||
(match pageNbr with | 1 -> "" | n -> sprintf " | Page %i" n)
|
||||
model.Subtitle <- Some <| match cat.Description with
|
||||
| Some desc -> desc
|
||||
| _ -> sprintf "Posts in the \"%s\" category" cat.Name
|
||||
this.ThemedView "index" model
|
||||
| _ -> this.NotFound ()
|
||||
|
||||
/// Display tagged posts
|
||||
member this.TaggedPosts (parameters : DynamicDictionary) : obj =
|
||||
let tag = parameters.["tag"].ToString ()
|
||||
let pageNbr = getPage parameters
|
||||
let model = PostsModel (this.Context, this.WebLog)
|
||||
model.PageNbr <- pageNbr
|
||||
model.Posts <- findPageOfTaggedPosts data this.WebLog.Id tag pageNbr 10 |> forDisplay
|
||||
model.HasNewer <- match List.isEmpty model.Posts with
|
||||
| true -> false
|
||||
| _ -> Option.isSome <| tryFindNewerTaggedPost data tag (List.head model.Posts).Post
|
||||
model.HasOlder <- match List.isEmpty model.Posts with
|
||||
| true -> false
|
||||
| _ -> Option.isSome <| tryFindOlderTaggedPost data tag (List.last model.Posts).Post
|
||||
model.UrlPrefix <- sprintf "/tag/%s" tag
|
||||
model.PageTitle <- sprintf "\"%s\" Tag%s" tag (match pageNbr with 1 -> "" | n -> sprintf " | Page %i" n)
|
||||
model.Subtitle <- Some <| sprintf "Posts tagged \"%s\"" tag
|
||||
this.ThemedView "index" model
|
||||
|
||||
/// Generate an RSS feed
|
||||
member this.Feed () : obj =
|
||||
let query = this.Request.Query :?> DynamicDictionary
|
||||
match query.ContainsKey "format" with
|
||||
| true ->
|
||||
match query.["format"].ToString () with
|
||||
| x when x = "atom" || x = "rss" -> generateFeed x
|
||||
| x when x = "rss2" -> generateFeed "rss"
|
||||
| _ -> this.Redirect "/feed" (MyWebLogModel (this.Context, this.WebLog))
|
||||
| _ -> generateFeed "rss"
|
||||
|
||||
// ---- Administer posts ----
|
||||
|
||||
/// Display a page of posts in the admin area
|
||||
member this.PostList pageNbr : obj =
|
||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
||||
let model = PostsModel (this.Context, this.WebLog)
|
||||
model.PageNbr <- pageNbr
|
||||
model.Posts <- findPageOfAllPosts data this.WebLog.Id pageNbr 25 |> forDisplay
|
||||
model.HasNewer <- pageNbr > 1
|
||||
model.HasOlder <- List.length model.Posts > 24
|
||||
model.UrlPrefix <- "/posts/list"
|
||||
model.PageTitle <- Strings.get "Posts"
|
||||
upcast this.View.["admin/post/list", model]
|
||||
|
||||
/// Edit a post
|
||||
member this.EditPost (parameters : DynamicDictionary) : obj =
|
||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
||||
let postId = parameters.["postId"].ToString ()
|
||||
match postId with "new" -> Some Post.Empty | _ -> tryFindPost data this.WebLog.Id postId
|
||||
|> function
|
||||
| Some post ->
|
||||
let rev =
|
||||
match post.Revisions
|
||||
|> List.sortByDescending (fun r -> r.AsOf)
|
||||
|> List.tryHead with
|
||||
| Some r -> r
|
||||
| None -> Revision.Empty
|
||||
let model = EditPostModel (this.Context, this.WebLog, post, rev)
|
||||
model.Categories <- findAllCategories data this.WebLog.Id
|
||||
|> List.map (fun cat ->
|
||||
DisplayCategory.Create cat (post.CategoryIds |> List.contains (fst cat).Id))
|
||||
model.PageTitle <- Strings.get <| match post.Id with "new" -> "AddNewPost" | _ -> "EditPost"
|
||||
upcast this.View.["admin/post/edit", model]
|
||||
| _ -> this.NotFound ()
|
||||
|
||||
/// Save a post
|
||||
member this.SavePost (parameters : DynamicDictionary) : obj =
|
||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
||||
this.ValidateCsrfToken ()
|
||||
let postId = parameters.["postId"].ToString ()
|
||||
let form = this.Bind<EditPostForm> ()
|
||||
let now = clock.GetCurrentInstant().ToUnixTimeTicks ()
|
||||
match postId with "new" -> Some Post.Empty | _ -> tryFindPost data this.WebLog.Id postId
|
||||
|> function
|
||||
| Some p ->
|
||||
let justPublished = p.PublishedOn = 0L && form.PublishNow
|
||||
let post =
|
||||
match postId with
|
||||
| "new" ->
|
||||
{ p with
|
||||
WebLogId = this.WebLog.Id
|
||||
AuthorId = this.Request.PersistableSession.GetOrDefault<User>(Keys.User, User.Empty).Id
|
||||
}
|
||||
| _ -> p
|
||||
let pId =
|
||||
{ post with
|
||||
Status = match form.PublishNow with true -> PostStatus.Published | _ -> PostStatus.Draft
|
||||
Title = form.Title
|
||||
Permalink = form.Permalink
|
||||
PublishedOn = match justPublished with true -> now | _ -> post.PublishedOn
|
||||
UpdatedOn = now
|
||||
Text = match form.Source with
|
||||
| RevisionSource.Markdown -> (* Markdown.TransformHtml *) form.Text
|
||||
| _ -> form.Text
|
||||
CategoryIds = Array.toList form.Categories
|
||||
Tags = form.Tags.Split ','
|
||||
|> Seq.map (fun t -> t.Trim().ToLowerInvariant ())
|
||||
|> Seq.sort
|
||||
|> Seq.toList
|
||||
Revisions = { AsOf = now
|
||||
SourceType = form.Source
|
||||
Text = form.Text } :: post.Revisions }
|
||||
|> savePost data
|
||||
let model = MyWebLogModel(this.Context, this.WebLog)
|
||||
model.AddMessage
|
||||
{ UserMessage.Empty with
|
||||
Message = System.String.Format
|
||||
(Strings.get "MsgPostEditSuccess",
|
||||
Strings.get (match postId with "new" -> "Added" | _ -> "Updated"),
|
||||
(match justPublished with true -> Strings.get "AndPublished" | _ -> ""))
|
||||
}
|
||||
this.Redirect (sprintf "/post/%s/edit" pId) model
|
||||
| _ -> this.NotFound ()
|
|
@ -1,64 +0,0 @@
|
|||
namespace MyWebLog
|
||||
|
||||
open MyWebLog.Data
|
||||
open MyWebLog.Entities
|
||||
open MyWebLog.Logic.User
|
||||
open MyWebLog.Resources
|
||||
open Nancy
|
||||
open Nancy.Authentication.Forms
|
||||
open Nancy.Cryptography
|
||||
open Nancy.ModelBinding
|
||||
open Nancy.Security
|
||||
open Nancy.Session.Persistable
|
||||
open RethinkDb.Driver.Net
|
||||
open System.Text
|
||||
|
||||
/// Handle /user URLs
|
||||
type UserModule (data : IMyWebLogData, cfg : AppConfig) as this =
|
||||
inherit NancyModule ("/user")
|
||||
|
||||
/// Hash the user's password
|
||||
let pbkdf2 (pw : string) =
|
||||
PassphraseKeyGenerator(pw, cfg.PasswordSalt, 4096).GetBytes 512
|
||||
|> Seq.fold (fun acc byt -> sprintf "%s%s" acc (byt.ToString "x2")) ""
|
||||
|
||||
do
|
||||
this.Get ("/log-on", fun _ -> this.ShowLogOn ())
|
||||
this.Post ("/log-on", fun p -> this.DoLogOn (downcast p))
|
||||
this.Get ("/log-off", fun _ -> this.LogOff ())
|
||||
|
||||
/// Show the log on page
|
||||
member this.ShowLogOn () : obj =
|
||||
let model = LogOnModel (this.Context, this.WebLog)
|
||||
let query = this.Request.Query :?> DynamicDictionary
|
||||
model.Form.ReturnUrl <- match query.ContainsKey "returnUrl" with true -> query.["returnUrl"].ToString () | _ -> ""
|
||||
model.PageTitle <- Strings.get "LogOn"
|
||||
upcast this.View.["admin/user/log-on", model]
|
||||
|
||||
/// Process a user log on
|
||||
member this.DoLogOn (parameters : DynamicDictionary) : obj =
|
||||
this.ValidateCsrfToken ()
|
||||
let form = this.Bind<LogOnForm> ()
|
||||
let model = MyWebLogModel(this.Context, this.WebLog)
|
||||
match tryUserLogOn data form.Email (pbkdf2 form.Password) with
|
||||
| Some user ->
|
||||
this.Session.[Keys.User] <- user
|
||||
model.AddMessage { UserMessage.Empty with Message = Strings.get "MsgLogOnSuccess" }
|
||||
this.Redirect "" model |> ignore // Save the messages in the session before the Nancy redirect
|
||||
// TODO: investigate if addMessage should update the session when it's called
|
||||
upcast this.LoginAndRedirect (System.Guid.Parse user.Id,
|
||||
fallbackRedirectUrl = defaultArg (Option.ofObj form.ReturnUrl) "/")
|
||||
| _ ->
|
||||
{ UserMessage.Empty with
|
||||
Level = Level.Error
|
||||
Message = Strings.get "ErrBadLogOnAttempt" }
|
||||
|> model.AddMessage
|
||||
this.Redirect (sprintf "/user/log-on?returnUrl=%s" form.ReturnUrl) model
|
||||
|
||||
/// Log a user off
|
||||
member this.LogOff () : obj =
|
||||
this.Session.DeleteAll ()
|
||||
let model = MyWebLogModel (this.Context, this.WebLog)
|
||||
model.AddMessage { UserMessage.Empty with Message = Strings.get "MsgLogOffSuccess" }
|
||||
this.Redirect "" model |> ignore
|
||||
upcast this.LogoutAndRedirect "/"
|
|
@ -1,21 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="14.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">14.0</VisualStudioVersion>
|
||||
<VSToolsPath Condition="'$(VSToolsPath)' == ''">$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)</VSToolsPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.Props" Condition="'$(VSToolsPath)' != ''" />
|
||||
<PropertyGroup Label="Globals">
|
||||
<ProjectGuid>9cea3a8b-e8aa-44e6-9f5f-2095ceed54eb</ProjectGuid>
|
||||
<RootNamespace>Nancy.Session.Persistable</RootNamespace>
|
||||
<BaseIntermediateOutputPath Condition="'$(BaseIntermediateOutputPath)'=='' ">.\obj</BaseIntermediateOutputPath>
|
||||
<OutputPath Condition="'$(OutputPath)'=='' ">.\bin\</OutputPath>
|
||||
<TargetFrameworkVersion>v4.5.2</TargetFrameworkVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<SchemaVersion>2.0</SchemaVersion>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.targets" Condition="'$(VSToolsPath)' != ''" />
|
||||
</Project>
|
|
@ -1,42 +0,0 @@
|
|||
module MyWebLog.Resources.Strings
|
||||
|
||||
open MyWebLog
|
||||
open Newtonsoft.Json
|
||||
open System.Collections.Generic
|
||||
open System.Reflection
|
||||
|
||||
/// 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 =
|
||||
use rdr =
|
||||
new System.IO.StreamReader
|
||||
(typeof<AppConfig>.GetTypeInfo().Assembly.GetManifestResourceStream(sprintf "MyWebLog.App.%s.json" locale))
|
||||
rdr.ReadToEnd()
|
||||
|
||||
/// The dictionary of localized strings
|
||||
let private strings =
|
||||
supportedLocales
|
||||
|> List.map (fun loc -> loc, JsonConvert.DeserializeObject<Dictionary<string, string>>(getEmbedded loc))
|
||||
|> dict
|
||||
|
||||
/// Get a key from the resources file for the given locale
|
||||
let getForLocale locale key =
|
||||
let getString thisLocale =
|
||||
match strings.ContainsKey thisLocale with
|
||||
| true -> match strings.[thisLocale].ContainsKey key with
|
||||
| true -> Some strings.[thisLocale].[key]
|
||||
| _ -> None
|
||||
| _ -> None
|
||||
match getString locale with
|
||||
| Some xlat -> Some xlat
|
||||
| _ when locale <> fallbackLocale -> getString fallbackLocale
|
||||
| _ -> None
|
||||
|> function Some xlat -> xlat | _ -> sprintf "%s.%s" locale key
|
||||
|
||||
/// Translate the key for the current locale
|
||||
let get key = getForLocale System.Globalization.CultureInfo.CurrentCulture.Name key
|
|
@ -1,504 +0,0 @@
|
|||
namespace MyWebLog
|
||||
|
||||
open MyWebLog.Entities
|
||||
open MyWebLog.Logic.WebLog
|
||||
open MyWebLog.Resources
|
||||
open Nancy
|
||||
open Nancy.Session.Persistable
|
||||
open Newtonsoft.Json
|
||||
open NodaTime
|
||||
open NodaTime.Text
|
||||
open System
|
||||
open System.Net
|
||||
|
||||
/// Levels for a user message
|
||||
[<RequireQualifiedAccess>]
|
||||
module Level =
|
||||
/// An informational message
|
||||
let Info = "Info"
|
||||
/// A message regarding a non-fatal but non-optimal condition
|
||||
let Warning = "WARNING"
|
||||
/// A message regarding a failure of the expected result
|
||||
let Error = "ERROR"
|
||||
|
||||
|
||||
/// A message for the user
|
||||
type UserMessage =
|
||||
{ /// The level of the message (use Level module constants)
|
||||
Level : string
|
||||
/// The text of the message
|
||||
Message : string
|
||||
/// Further details regarding the message
|
||||
Details : string option }
|
||||
with
|
||||
/// An empty message
|
||||
static member Empty =
|
||||
{ Level = Level.Info
|
||||
Message = ""
|
||||
Details = None }
|
||||
|
||||
/// Display version
|
||||
[<JsonIgnore>]
|
||||
member this.ToDisplay =
|
||||
let classAndLabel =
|
||||
dict [
|
||||
Level.Error, ("danger", Strings.get "Error")
|
||||
Level.Warning, ("warning", Strings.get "Warning")
|
||||
Level.Info, ("info", "")
|
||||
]
|
||||
seq {
|
||||
yield "<div class=\"alert alert-dismissable alert-"
|
||||
yield fst classAndLabel.[this.Level]
|
||||
yield "\" role=\"alert\"><button type=\"button\" class=\"close\" data-dismiss=\"alert\" aria-label=\""
|
||||
yield Strings.get "Close"
|
||||
yield "\">×</button><strong>"
|
||||
match snd classAndLabel.[this.Level] with
|
||||
| "" -> ()
|
||||
| lbl -> yield lbl.ToUpper ()
|
||||
yield " » "
|
||||
yield this.Message
|
||||
yield "</strong>"
|
||||
match this.Details with
|
||||
| Some d -> yield "<br />"
|
||||
yield d
|
||||
| None -> ()
|
||||
yield "</div>"
|
||||
}
|
||||
|> Seq.reduce (+)
|
||||
|
||||
|
||||
/// Helpers to format local date/time using NodaTime
|
||||
module FormatDateTime =
|
||||
|
||||
/// Convert ticks to a zoned date/time
|
||||
let zonedTime timeZone ticks = Instant.FromUnixTimeTicks(ticks).InZone(DateTimeZoneProviders.Tzdb.[timeZone])
|
||||
|
||||
/// Display a long date
|
||||
let longDate timeZone ticks =
|
||||
zonedTime timeZone ticks
|
||||
|> ZonedDateTimePattern.CreateWithCurrentCulture("MMMM d',' yyyy", DateTimeZoneProviders.Tzdb).Format
|
||||
|
||||
/// Display a short date
|
||||
let shortDate timeZone ticks =
|
||||
zonedTime timeZone ticks
|
||||
|> ZonedDateTimePattern.CreateWithCurrentCulture("MMM d',' yyyy", DateTimeZoneProviders.Tzdb).Format
|
||||
|
||||
/// Display the time
|
||||
let time timeZone ticks =
|
||||
(zonedTime timeZone ticks
|
||||
|> ZonedDateTimePattern.CreateWithCurrentCulture("h':'mmtt", DateTimeZoneProviders.Tzdb).Format).ToLower ()
|
||||
|
||||
|
||||
/// Parent view model for all myWebLog views
|
||||
type MyWebLogModel (ctx : NancyContext, webLog : WebLog) as this =
|
||||
|
||||
/// Get the messages from the session
|
||||
let getMessages () =
|
||||
let msg = ctx.Request.PersistableSession.GetOrDefault<UserMessage list> (Keys.Messages, [])
|
||||
match List.length msg with
|
||||
| 0 -> ()
|
||||
| _ -> ctx.Request.Session.Delete Keys.Messages
|
||||
msg
|
||||
|
||||
/// Generate a footer logo with the given scheme
|
||||
let footerLogo scheme =
|
||||
seq {
|
||||
yield sprintf "<img src=\"/content/logo-%s.png\" alt=\"myWebLog\" title=\"" scheme
|
||||
yield sprintf "%s %s • " (Strings.get "PoweredBy") this.Generator
|
||||
yield Strings.get "LoadedIn"
|
||||
yield " "
|
||||
yield TimeSpan(System.DateTime.Now.Ticks - this.RequestStart).TotalSeconds.ToString "f3"
|
||||
yield " "
|
||||
yield (Strings.get "Seconds").ToLower ()
|
||||
yield "\" height=\"30\" />"
|
||||
}
|
||||
|> Seq.reduce (+)
|
||||
|
||||
/// The web log for this request
|
||||
member this.WebLog = webLog
|
||||
/// The subtitle for the webLog (SSVE can't do IsSome that deep)
|
||||
member this.WebLogSubtitle = defaultArg this.WebLog.Subtitle ""
|
||||
/// User messages
|
||||
member val Messages = getMessages () with get, set
|
||||
/// The currently logged in user
|
||||
member this.User = ctx.Request.PersistableSession.GetOrDefault<User> (Keys.User, User.Empty)
|
||||
/// The title of the page
|
||||
member val PageTitle = "" with get, set
|
||||
/// The name and version of the application
|
||||
member this.Generator = sprintf "myWebLog %s" (ctx.Items.[Keys.Version].ToString ())
|
||||
/// The request start time
|
||||
member this.RequestStart = ctx.Items.[Keys.RequestStart] :?> int64
|
||||
/// Is a user authenticated for this request?
|
||||
member this.IsAuthenticated = "" <> this.User.Id
|
||||
/// Add a message to the output
|
||||
member this.AddMessage message = this.Messages <- message :: this.Messages
|
||||
|
||||
/// Display a long date
|
||||
member this.DisplayLongDate ticks = FormatDateTime.longDate this.WebLog.TimeZone ticks
|
||||
/// Display a short date
|
||||
member this.DisplayShortDate ticks = FormatDateTime.shortDate this.WebLog.TimeZone ticks
|
||||
/// Display the time
|
||||
member this.DisplayTime ticks = FormatDateTime.time this.WebLog.TimeZone ticks
|
||||
/// The page title with the web log name appended
|
||||
member this.DisplayPageTitle =
|
||||
match this.PageTitle with
|
||||
| "" ->
|
||||
match this.WebLog.Subtitle with
|
||||
| Some st -> sprintf "%s | %s" this.WebLog.Name st
|
||||
| None -> this.WebLog.Name
|
||||
| pt -> sprintf "%s | %s" pt this.WebLog.Name
|
||||
|
||||
/// An image with the version and load time in the tool tip (using light text)
|
||||
member this.FooterLogoLight = footerLogo "light"
|
||||
|
||||
/// An image with the version and load time in the tool tip (using dark text)
|
||||
member this.FooterLogoDark = footerLogo "dark"
|
||||
|
||||
|
||||
// ---- Admin models ----
|
||||
|
||||
/// Admin Dashboard view model
|
||||
type DashboardModel (ctx, webLog, counts : DashboardCounts) =
|
||||
inherit MyWebLogModel (ctx, webLog)
|
||||
/// The number of posts for the current web log
|
||||
member val Posts = counts.Posts with get, set
|
||||
/// The number of pages for the current web log
|
||||
member val Pages = counts.Pages with get, set
|
||||
/// The number of categories for the current web log
|
||||
member val Categories = counts.Categories with get, set
|
||||
|
||||
|
||||
// ---- Category models ----
|
||||
|
||||
type IndentedCategory =
|
||||
{ Category : Category
|
||||
Indent : int
|
||||
Selected : bool }
|
||||
with
|
||||
/// Create an indented category
|
||||
static member Create cat isSelected =
|
||||
{ Category = fst cat
|
||||
Indent = snd cat
|
||||
Selected = isSelected (fst cat).Id }
|
||||
/// Display name for a category on the list page, complete with indents
|
||||
member this.ListName = sprintf "%s%s" (String.replicate this.Indent " » ") this.Category.Name
|
||||
/// Display for this category as an option within a select box
|
||||
member this.Option =
|
||||
seq {
|
||||
yield sprintf "<option value=\"%s\"" this.Category.Id
|
||||
yield (match this.Selected with | true -> """ selected="selected">""" | _ -> ">")
|
||||
yield String.replicate this.Indent " "
|
||||
yield this.Category.Name
|
||||
yield "</option>"
|
||||
}
|
||||
|> String.concat ""
|
||||
/// Does the category have a description?
|
||||
member this.HasDescription = this.Category.Description.IsSome
|
||||
|
||||
|
||||
/// Model for the list of categories
|
||||
type CategoryListModel (ctx, webLog, categories) =
|
||||
inherit MyWebLogModel (ctx, webLog)
|
||||
/// The categories
|
||||
member this.Categories : IndentedCategory list = categories
|
||||
|
||||
|
||||
/// Form for editing a category
|
||||
type CategoryForm (category : Category) =
|
||||
new() = CategoryForm (Category.Empty)
|
||||
/// The name of the category
|
||||
member val Name = category.Name with get, set
|
||||
/// The slug of the category (used in category URLs)
|
||||
member val Slug = category.Slug with get, set
|
||||
/// The description of the category
|
||||
member val Description = defaultArg category.Description "" with get, set
|
||||
/// The parent category for this one
|
||||
member val ParentId = defaultArg category.ParentId "" with get, set
|
||||
|
||||
/// Model for editing a category
|
||||
type CategoryEditModel (ctx, webLog, category) =
|
||||
inherit MyWebLogModel (ctx, webLog)
|
||||
/// The form with the category information
|
||||
member val Form = CategoryForm (category) with get, set
|
||||
/// The category being edited
|
||||
member val Category = category
|
||||
/// The categories
|
||||
member val Categories : IndentedCategory list = [] with get, set
|
||||
|
||||
|
||||
// ---- Page models ----
|
||||
|
||||
/// Model for page display
|
||||
type PageModel (ctx, webLog, page) =
|
||||
inherit MyWebLogModel (ctx, webLog)
|
||||
/// The page to be displayed
|
||||
member this.Page : Page = page
|
||||
|
||||
|
||||
/// Wrapper for a page with additional properties
|
||||
type PageForDisplay (webLog, page) =
|
||||
/// The page
|
||||
member this.Page : Page = page
|
||||
/// The time zone of the web log
|
||||
member this.TimeZone = webLog.TimeZone
|
||||
/// The date the page was last updated
|
||||
member this.UpdatedDate = FormatDateTime.longDate this.TimeZone page.UpdatedOn
|
||||
/// The time the page was last updated
|
||||
member this.UpdatedTime = FormatDateTime.time this.TimeZone page.UpdatedOn
|
||||
|
||||
|
||||
/// Model for page list display
|
||||
type PagesModel (ctx, webLog, pages) =
|
||||
inherit MyWebLogModel (ctx, webLog)
|
||||
/// The pages
|
||||
member this.Pages : PageForDisplay list = pages
|
||||
|
||||
|
||||
/// Form used to edit a page
|
||||
type EditPageForm() =
|
||||
/// The title of the page
|
||||
member val Title = "" with get, set
|
||||
/// The link for the page
|
||||
member val Permalink = "" with get, set
|
||||
/// The source type of the revision
|
||||
member val Source = "" with get, set
|
||||
/// The text of the revision
|
||||
member val Text = "" with get, set
|
||||
/// Whether to show the page in the web log's page list
|
||||
member val ShowInPageList = false with get, set
|
||||
|
||||
/// Fill the form with applicable values from a page
|
||||
member this.ForPage (page : Page) =
|
||||
this.Title <- page.Title
|
||||
this.Permalink <- page.Permalink
|
||||
this.ShowInPageList <- page.ShowInPageList
|
||||
this
|
||||
|
||||
/// Fill the form with applicable values from a revision
|
||||
member this.ForRevision rev =
|
||||
this.Source <- rev.SourceType
|
||||
this.Text <- rev.Text
|
||||
this
|
||||
|
||||
|
||||
/// Model for the edit page page
|
||||
type EditPageModel (ctx, webLog, page, revision) =
|
||||
inherit MyWebLogModel (ctx, webLog)
|
||||
/// The page edit form
|
||||
member val Form = EditPageForm().ForPage(page).ForRevision(revision)
|
||||
/// The page itself
|
||||
member this.Page = page
|
||||
/// The page's published date
|
||||
member this.PublishedDate = this.DisplayLongDate page.PublishedOn
|
||||
/// The page's published time
|
||||
member this.PublishedTime = this.DisplayTime page.PublishedOn
|
||||
/// The page's last updated date
|
||||
member this.LastUpdatedDate = this.DisplayLongDate page.UpdatedOn
|
||||
/// The page's last updated time
|
||||
member this.LastUpdatedTime = this.DisplayTime page.UpdatedOn
|
||||
/// Is this a new page?
|
||||
member this.IsNew = "new" = page.Id
|
||||
/// Generate a checked attribute if this page shows in the page list
|
||||
member this.PageListChecked = match page.ShowInPageList with true -> "checked=\"checked\"" | _ -> ""
|
||||
|
||||
|
||||
// ---- Post models ----
|
||||
|
||||
/// Formatter for comment information
|
||||
type CommentForDisplay (comment : Comment, tz) =
|
||||
/// The comment on which this model is based
|
||||
member this.Comment = comment
|
||||
/// The commentor (linked with a URL if there is one)
|
||||
member this.Commentor =
|
||||
match comment.Url with Some url -> sprintf "<a href=\"%s\">%s</a>" url comment.Name | _ -> comment.Name
|
||||
/// The date/time this comment was posted
|
||||
member this.CommentedOn =
|
||||
sprintf "%s / %s" (FormatDateTime.longDate tz comment.PostedOn) (FormatDateTime.time tz comment.PostedOn)
|
||||
|
||||
/// Model for single post display
|
||||
type PostModel (ctx, webLog, post) =
|
||||
inherit MyWebLogModel (ctx, webLog)
|
||||
/// The post being displayed
|
||||
member this.Post : Post = post
|
||||
/// The next newer post
|
||||
member val NewerPost : Post option = None with get, set
|
||||
/// The next older post
|
||||
member val OlderPost : Post option = None with get, set
|
||||
/// The date the post was published
|
||||
member this.PublishedDate = this.DisplayLongDate this.Post.PublishedOn
|
||||
/// The time the post was published
|
||||
member this.PublishedTime = this.DisplayTime this.Post.PublishedOn
|
||||
/// The number of comments
|
||||
member this.CommentCount =
|
||||
match post.Comments |> List.length with
|
||||
| 0 -> Strings.get "NoComments"
|
||||
| 1 -> Strings.get "OneComment"
|
||||
| x -> String.Format (Strings.get "XComments", x)
|
||||
/// The comments for display
|
||||
member this.Comments = post.Comments
|
||||
|> List.filter (fun c -> c.Status = CommentStatus.Approved)
|
||||
|> List.map (fun c -> CommentForDisplay (c, webLog.TimeZone))
|
||||
|
||||
/// Does the post have tags?
|
||||
member this.HasTags = not <| List.isEmpty post.Tags
|
||||
/// Get the tags sorted
|
||||
member this.Tags = post.Tags
|
||||
|> List.sort
|
||||
|> List.map (fun tag -> tag, tag.Replace(' ', '+'))
|
||||
/// Does this post have a newer post?
|
||||
member this.HasNewer = this.NewerPost.IsSome
|
||||
/// Does this post have an older post?
|
||||
member this.HasOlder = this.OlderPost.IsSome
|
||||
|
||||
|
||||
/// Wrapper for a post with additional properties
|
||||
type PostForDisplay (webLog : WebLog, post : Post) =
|
||||
/// Turn tags into a pipe-delimited string of tags
|
||||
let pipedTags tags = tags |> List.reduce (fun acc x -> sprintf "%s | %s" acc x)
|
||||
/// The actual post
|
||||
member this.Post = post
|
||||
/// The time zone for the web log to which this post belongs
|
||||
member this.TimeZone = webLog.TimeZone
|
||||
/// The date the post was published
|
||||
member this.PublishedDate =
|
||||
match this.Post.Status with
|
||||
| PostStatus.Published -> FormatDateTime.longDate this.TimeZone this.Post.PublishedOn
|
||||
| _ -> FormatDateTime.longDate this.TimeZone this.Post.UpdatedOn
|
||||
/// The time the post was published
|
||||
member this.PublishedTime =
|
||||
match this.Post.Status with
|
||||
| PostStatus.Published -> FormatDateTime.time this.TimeZone this.Post.PublishedOn
|
||||
| _ -> FormatDateTime.time this.TimeZone this.Post.UpdatedOn
|
||||
/// The number of comments
|
||||
member this.CommentCount =
|
||||
match post.Comments |> List.length with
|
||||
| 0 -> Strings.get "NoComments"
|
||||
| 1 -> Strings.get "OneComment"
|
||||
| x -> String.Format (Strings.get "XComments", x)
|
||||
/// Tags
|
||||
member this.Tags =
|
||||
match List.length this.Post.Tags with
|
||||
| 0 -> ""
|
||||
| 1 | 2 | 3 | 4 | 5 -> this.Post.Tags |> pipedTags
|
||||
| count -> sprintf "%s %s" (this.Post.Tags |> List.take 3 |> pipedTags)
|
||||
(System.String.Format(Strings.get "andXMore", count - 3))
|
||||
|
||||
|
||||
/// Model for all page-of-posts pages
|
||||
type PostsModel (ctx, webLog) =
|
||||
inherit MyWebLogModel (ctx, webLog)
|
||||
/// The subtitle for the page
|
||||
member val Subtitle : string option = None with get, set
|
||||
/// The posts to display
|
||||
member val Posts : PostForDisplay list = [] with get, set
|
||||
/// The page number of the post list
|
||||
member val PageNbr = 0 with get, set
|
||||
/// Whether there is a newer page of posts for the list
|
||||
member val HasNewer = false with get, set
|
||||
/// Whether there is an older page of posts for the list
|
||||
member val HasOlder = true with get, set
|
||||
/// The prefix for the next/prior links
|
||||
member val UrlPrefix = "" with get, set
|
||||
|
||||
/// The link for the next newer page of posts
|
||||
member this.NewerLink =
|
||||
match this.UrlPrefix = "/posts" && this.PageNbr = 2 && this.WebLog.DefaultPage = "posts" with
|
||||
| true -> "/"
|
||||
| _ -> sprintf "%s/page/%i" this.UrlPrefix (this.PageNbr - 1)
|
||||
|
||||
/// The link for the prior (older) page of posts
|
||||
member this.OlderLink = sprintf "%s/page/%i" this.UrlPrefix (this.PageNbr + 1)
|
||||
|
||||
|
||||
/// Form for editing a post
|
||||
type EditPostForm () =
|
||||
/// The title of the post
|
||||
member val Title = "" with get, set
|
||||
/// The permalink for the post
|
||||
member val Permalink = "" with get, set
|
||||
/// The source type for this revision
|
||||
member val Source = "" with get, set
|
||||
/// The text
|
||||
member val Text = "" with get, set
|
||||
/// Tags for the post
|
||||
member val Tags = "" with get, set
|
||||
/// The selected category Ids for the post
|
||||
member val Categories : string[] = [||] with get, set
|
||||
/// Whether the post should be published
|
||||
member val PublishNow = false with get, set
|
||||
|
||||
/// Fill the form with applicable values from a post
|
||||
member this.ForPost (post : Post) =
|
||||
this.Title <- post.Title
|
||||
this.Permalink <- post.Permalink
|
||||
this.Tags <- match List.isEmpty post.Tags with
|
||||
| true -> ""
|
||||
| _ -> List.reduce (fun acc x -> sprintf "%s, %s" acc x) post.Tags
|
||||
this.Categories <- List.toArray post.CategoryIds
|
||||
this.PublishNow <- post.Status = PostStatus.Published || "new" = post.Id
|
||||
this
|
||||
|
||||
/// Fill the form with applicable values from a revision
|
||||
member this.ForRevision rev =
|
||||
this.Source <- rev.SourceType
|
||||
this.Text <- rev.Text
|
||||
this
|
||||
|
||||
/// Category information for display
|
||||
type DisplayCategory = {
|
||||
Id : string
|
||||
Indent : string
|
||||
Name : string
|
||||
Description : string
|
||||
IsChecked : bool
|
||||
}
|
||||
with
|
||||
/// Create a display category
|
||||
static member Create (cat : Category, indent) isChecked =
|
||||
{ Id = cat.Id
|
||||
Indent = String.replicate indent " "
|
||||
Name = WebUtility.HtmlEncode cat.Name
|
||||
IsChecked = isChecked
|
||||
Description = WebUtility.HtmlEncode (match cat.Description with Some d -> d | _ -> cat.Name)
|
||||
}
|
||||
/// The "checked" attribute for this category
|
||||
member this.CheckedAttr
|
||||
with get() = match this.IsChecked with true -> "checked=\"checked\"" | _ -> ""
|
||||
|
||||
/// View model for the edit post page
|
||||
type EditPostModel (ctx, webLog, post, revision) =
|
||||
inherit MyWebLogModel (ctx, webLog)
|
||||
|
||||
/// The form
|
||||
member val Form = EditPostForm().ForPost(post).ForRevision(revision) with get, set
|
||||
/// The post being edited
|
||||
member val Post = post with get, set
|
||||
/// The categories to which the post may be assigned
|
||||
member val Categories : DisplayCategory list = [] with get, set
|
||||
/// Whether the post is currently published
|
||||
member this.IsPublished = PostStatus.Published = this.Post.Status
|
||||
/// The published date
|
||||
member this.PublishedDate = this.DisplayLongDate this.Post.PublishedOn
|
||||
/// The published time
|
||||
member this.PublishedTime = this.DisplayTime this.Post.PublishedOn
|
||||
/// The "checked" attribute for the Publish Now box
|
||||
member this.PublishNowCheckedAttr = match this.Form.PublishNow with true -> "checked=\"checked\"" | _ -> ""
|
||||
|
||||
|
||||
// ---- User models ----
|
||||
|
||||
/// Form for the log on page
|
||||
type LogOnForm () =
|
||||
/// The URL to which the user will be directed upon successful log on
|
||||
member val ReturnUrl = "" with get, set
|
||||
/// The e-mail address
|
||||
member val Email = "" with get, set
|
||||
/// The user's passwor
|
||||
member val Password = "" with get, set
|
||||
|
||||
|
||||
/// Model to support the user log on page
|
||||
type LogOnModel (ctx, webLog) =
|
||||
inherit MyWebLogModel (ctx, webLog)
|
||||
/// The log on form
|
||||
member val Form = LogOnForm () with get, set
|
|
@ -1,83 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
|
@ -1,64 +0,0 @@
|
|||
{
|
||||
"buildOptions": {
|
||||
"compilerName": "fsc",
|
||||
"compile": {
|
||||
"includeFiles": [
|
||||
"AssemblyInfo.fs",
|
||||
"Entities/Entities.fs",
|
||||
"Entities/IMyWebLogData.fs",
|
||||
"Data/Extensions.fs",
|
||||
"Data/Table.fs",
|
||||
"Data/DataConfig.fs",
|
||||
"Data/Category.fs",
|
||||
"Data/Page.fs",
|
||||
"Data/Post.fs",
|
||||
"Data/User.fs",
|
||||
"Data/WebLog.fs",
|
||||
"Data/SetUp.fs",
|
||||
"Data/RethinkMyWebLogData.fs",
|
||||
"Logic/Category.fs",
|
||||
"Logic/Page.fs",
|
||||
"Logic/Post.fs",
|
||||
"Logic/User.fs",
|
||||
"Logic/WebLog.fs",
|
||||
"Keys.fs",
|
||||
"AppConfig.fs",
|
||||
"Strings.fs",
|
||||
"ViewModels.fs",
|
||||
"Modules/ModuleExtensions.fs",
|
||||
"Modules/AdminModule.fs",
|
||||
"Modules/CategoryModule.fs",
|
||||
"Modules/PageModule.fs",
|
||||
"Modules/PostModule.fs",
|
||||
"Modules/UserModule.fs",
|
||||
"App.fs"
|
||||
]
|
||||
},
|
||||
"embed": {
|
||||
"include": [ "en-US.json" ]
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"Nancy": "2.0.0-barneyrubble",
|
||||
"Nancy.Authentication.Forms": "2.0.0-barneyrubble",
|
||||
"Nancy.Session.Persistable": "0.9.1-pre",
|
||||
"Nancy.Session.RethinkDB": "0.9.1-pre",
|
||||
"Newtonsoft.Json": "9.0.1",
|
||||
"NodaTime": "2.0.0-alpha20160729",
|
||||
"RethinkDb.Driver": "2.3.15",
|
||||
"Suave": "2.0.0-rc2"
|
||||
},
|
||||
"frameworks": {
|
||||
"netstandard1.6": {
|
||||
"imports": "dnxcore50",
|
||||
"dependencies": {
|
||||
"Microsoft.FSharp.Core.netcore": "1.0.0-alpha-161111",
|
||||
"NETStandard.Library": "1.6.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"dotnet-compile-fsc": "1.0.0-preview2-*"
|
||||
},
|
||||
"version": "0.9.2"
|
||||
}
|
132
src/MyWebLog.Data/Converters.fs
Normal file
132
src/MyWebLog.Data/Converters.fs
Normal file
|
@ -0,0 +1,132 @@
|
|||
/// Converters for discriminated union types
|
||||
module MyWebLog.Converters
|
||||
|
||||
open MyWebLog
|
||||
open System
|
||||
|
||||
/// JSON.NET converters for discriminated union types
|
||||
module Json =
|
||||
|
||||
open Newtonsoft.Json
|
||||
|
||||
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 CustomFeedIdConverter () =
|
||||
inherit JsonConverter<CustomFeedId> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : CustomFeedId, _ : JsonSerializer) =
|
||||
writer.WriteValue (CustomFeedId.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : CustomFeedId, _ : bool, _ : JsonSerializer) =
|
||||
(string >> CustomFeedId) reader.Value
|
||||
|
||||
type CustomFeedSourceConverter () =
|
||||
inherit JsonConverter<CustomFeedSource> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : CustomFeedSource, _ : JsonSerializer) =
|
||||
writer.WriteValue (CustomFeedSource.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : CustomFeedSource, _ : bool, _ : JsonSerializer) =
|
||||
(string >> CustomFeedSource.parse) reader.Value
|
||||
|
||||
type ExplicitRatingConverter () =
|
||||
inherit JsonConverter<ExplicitRating> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : ExplicitRating, _ : JsonSerializer) =
|
||||
writer.WriteValue (ExplicitRating.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : ExplicitRating, _ : bool, _ : JsonSerializer) =
|
||||
(string >> ExplicitRating.parse) reader.Value
|
||||
|
||||
type MarkupTextConverter () =
|
||||
inherit JsonConverter<MarkupText> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : MarkupText, _ : JsonSerializer) =
|
||||
writer.WriteValue (MarkupText.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : MarkupText, _ : bool, _ : JsonSerializer) =
|
||||
(string >> MarkupText.parse) 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 TagMapIdConverter () =
|
||||
inherit JsonConverter<TagMapId> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : TagMapId, _ : JsonSerializer) =
|
||||
writer.WriteValue (TagMapId.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : TagMapId, _ : bool, _ : JsonSerializer) =
|
||||
(string >> TagMapId) reader.Value
|
||||
|
||||
type ThemeAssetIdConverter () =
|
||||
inherit JsonConverter<ThemeAssetId> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : ThemeAssetId, _ : JsonSerializer) =
|
||||
writer.WriteValue (ThemeAssetId.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : ThemeAssetId, _ : bool, _ : JsonSerializer) =
|
||||
(string >> ThemeAssetId.ofString) reader.Value
|
||||
|
||||
type ThemeIdConverter () =
|
||||
inherit JsonConverter<ThemeId> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : ThemeId, _ : JsonSerializer) =
|
||||
writer.WriteValue (ThemeId.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : ThemeId, _ : bool, _ : JsonSerializer) =
|
||||
(string >> ThemeId) 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 {
|
||||
// Our converters
|
||||
CategoryIdConverter ()
|
||||
CommentIdConverter ()
|
||||
CustomFeedIdConverter ()
|
||||
CustomFeedSourceConverter ()
|
||||
ExplicitRatingConverter ()
|
||||
MarkupTextConverter ()
|
||||
PermalinkConverter ()
|
||||
PageIdConverter ()
|
||||
PostIdConverter ()
|
||||
TagMapIdConverter ()
|
||||
ThemeAssetIdConverter ()
|
||||
ThemeIdConverter ()
|
||||
WebLogIdConverter ()
|
||||
WebLogUserIdConverter ()
|
||||
// Handles DUs with no associated data, as well as option fields
|
||||
CompactUnionJsonConverter ()
|
||||
}
|
281
src/MyWebLog.Data/Interfaces.fs
Normal file
281
src/MyWebLog.Data/Interfaces.fs
Normal file
|
@ -0,0 +1,281 @@
|
|||
namespace MyWebLog.Data
|
||||
|
||||
open System
|
||||
open System.Threading.Tasks
|
||||
open MyWebLog
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
/// Data functions to support manipulating categories
|
||||
type ICategoryData =
|
||||
|
||||
/// Add a category
|
||||
abstract member add : Category -> Task<unit>
|
||||
|
||||
/// Count all categories for the given web log
|
||||
abstract member countAll : WebLogId -> Task<int>
|
||||
|
||||
/// Count all top-level categories for the given web log
|
||||
abstract member countTopLevel : WebLogId -> Task<int>
|
||||
|
||||
/// Delete a category (also removes it from posts)
|
||||
abstract member delete : CategoryId -> WebLogId -> Task<bool>
|
||||
|
||||
/// Find all categories for a web log, sorted alphabetically and grouped by hierarchy
|
||||
abstract member findAllForView : WebLogId -> Task<DisplayCategory[]>
|
||||
|
||||
/// Find a category by its ID
|
||||
abstract member findById : CategoryId -> WebLogId -> Task<Category option>
|
||||
|
||||
/// Find all categories for the given web log
|
||||
abstract member findByWebLog : WebLogId -> Task<Category list>
|
||||
|
||||
/// Restore categories from a backup
|
||||
abstract member restore : Category list -> Task<unit>
|
||||
|
||||
/// Update a category (slug, name, description, and parent ID)
|
||||
abstract member update : Category -> Task<unit>
|
||||
|
||||
|
||||
/// Data functions to support manipulating pages
|
||||
type IPageData =
|
||||
|
||||
/// Add a page
|
||||
abstract member add : Page -> Task<unit>
|
||||
|
||||
/// Get all pages for the web log (excluding meta items, text, revisions, and prior permalinks)
|
||||
abstract member all : WebLogId -> Task<Page list>
|
||||
|
||||
/// Count all pages for the given web log
|
||||
abstract member countAll : WebLogId -> Task<int>
|
||||
|
||||
/// Count pages marked as "show in page list" for the given web log
|
||||
abstract member countListed : WebLogId -> Task<int>
|
||||
|
||||
/// Delete a page
|
||||
abstract member delete : PageId -> WebLogId -> Task<bool>
|
||||
|
||||
/// Find a page by its ID (excluding revisions and prior permalinks)
|
||||
abstract member findById : PageId -> WebLogId -> Task<Page option>
|
||||
|
||||
/// Find a page by its permalink (excluding revisions and prior permalinks)
|
||||
abstract member findByPermalink : Permalink -> WebLogId -> Task<Page option>
|
||||
|
||||
/// Find the current permalink for a page from a list of prior permalinks
|
||||
abstract member findCurrentPermalink : Permalink list -> WebLogId -> Task<Permalink option>
|
||||
|
||||
/// Find a page by its ID (including revisions and prior permalinks)
|
||||
abstract member findFullById : PageId -> WebLogId -> Task<Page option>
|
||||
|
||||
/// Find all pages for the given web log (including revisions and prior permalinks)
|
||||
abstract member findFullByWebLog : WebLogId -> Task<Page list>
|
||||
|
||||
/// Find pages marked as "show in page list" for the given web log (excluding text, revisions, and prior permalinks)
|
||||
abstract member findListed : WebLogId -> Task<Page list>
|
||||
|
||||
/// Find a page of pages (displayed in admin section) (excluding meta items, revisions and prior permalinks)
|
||||
abstract member findPageOfPages : WebLogId -> pageNbr : int -> Task<Page list>
|
||||
|
||||
/// Restore pages from a backup
|
||||
abstract member restore : Page list -> Task<unit>
|
||||
|
||||
/// Update a page
|
||||
abstract member update : Page -> Task<unit>
|
||||
|
||||
/// Update the prior permalinks for the given page
|
||||
abstract member updatePriorPermalinks : PageId -> WebLogId -> Permalink list -> Task<bool>
|
||||
|
||||
|
||||
/// Data functions to support manipulating posts
|
||||
type IPostData =
|
||||
|
||||
/// Add a post
|
||||
abstract member add : Post -> Task<unit>
|
||||
|
||||
/// Count posts by their status
|
||||
abstract member countByStatus : PostStatus -> WebLogId -> Task<int>
|
||||
|
||||
/// Delete a post
|
||||
abstract member delete : PostId -> WebLogId -> Task<bool>
|
||||
|
||||
/// Find a post by its permalink (excluding revisions and prior permalinks)
|
||||
abstract member findByPermalink : Permalink -> WebLogId -> Task<Post option>
|
||||
|
||||
/// Find the current permalink for a post from a list of prior permalinks
|
||||
abstract member findCurrentPermalink : Permalink list -> WebLogId -> Task<Permalink option>
|
||||
|
||||
/// Find a post by its ID (including revisions and prior permalinks)
|
||||
abstract member findFullById : PostId -> WebLogId -> Task<Post option>
|
||||
|
||||
/// Find all posts for the given web log (including revisions and prior permalinks)
|
||||
abstract member findFullByWebLog : WebLogId -> Task<Post list>
|
||||
|
||||
/// Find posts to be displayed on a category list page (excluding revisions and prior permalinks)
|
||||
abstract member findPageOfCategorizedPosts :
|
||||
WebLogId -> CategoryId list -> pageNbr : int -> postsPerPage : int -> Task<Post list>
|
||||
|
||||
/// Find posts to be displayed on an admin page (excluding revisions and prior permalinks)
|
||||
abstract member findPageOfPosts : WebLogId -> pageNbr : int -> postsPerPage : int -> Task<Post list>
|
||||
|
||||
/// Find posts to be displayed on a page (excluding revisions and prior permalinks)
|
||||
abstract member findPageOfPublishedPosts : WebLogId -> pageNbr : int -> postsPerPage : int -> Task<Post list>
|
||||
|
||||
/// Find posts to be displayed on a tag list page (excluding revisions and prior permalinks)
|
||||
abstract member findPageOfTaggedPosts :
|
||||
WebLogId -> tag : string -> pageNbr : int -> postsPerPage : int -> Task<Post list>
|
||||
|
||||
/// Find the next older and newer post for the given published date/time (excluding revisions and prior permalinks)
|
||||
abstract member findSurroundingPosts : WebLogId -> publishedOn : DateTime -> Task<Post option * Post option>
|
||||
|
||||
/// Restore posts from a backup
|
||||
abstract member restore : Post list -> Task<unit>
|
||||
|
||||
/// Update a post
|
||||
abstract member update : Post -> Task<unit>
|
||||
|
||||
/// Update the prior permalinks for a post
|
||||
abstract member updatePriorPermalinks : PostId -> WebLogId -> Permalink list -> Task<bool>
|
||||
|
||||
|
||||
/// Functions to manipulate tag mappings
|
||||
type ITagMapData =
|
||||
|
||||
/// Delete a tag mapping
|
||||
abstract member delete : TagMapId -> WebLogId -> Task<bool>
|
||||
|
||||
/// Find a tag mapping by its ID
|
||||
abstract member findById : TagMapId -> WebLogId -> Task<TagMap option>
|
||||
|
||||
/// Find a tag mapping by its URL value
|
||||
abstract member findByUrlValue : string -> WebLogId -> Task<TagMap option>
|
||||
|
||||
/// Retrieve all tag mappings for the given web log
|
||||
abstract member findByWebLog : WebLogId -> Task<TagMap list>
|
||||
|
||||
/// Find tag mappings for the given tags
|
||||
abstract member findMappingForTags : tags : string list -> WebLogId -> Task<TagMap list>
|
||||
|
||||
/// Restore tag mappings from a backup
|
||||
abstract member restore : TagMap list -> Task<unit>
|
||||
|
||||
/// Save a tag mapping (insert or update)
|
||||
abstract member save : TagMap -> Task<unit>
|
||||
|
||||
|
||||
/// Functions to manipulate themes
|
||||
type IThemeData =
|
||||
|
||||
/// Retrieve all themes (except "admin")
|
||||
abstract member all : unit -> Task<Theme list>
|
||||
|
||||
/// Find a theme by its ID
|
||||
abstract member findById : ThemeId -> Task<Theme option>
|
||||
|
||||
/// Find a theme by its ID (excluding the text of its templates)
|
||||
abstract member findByIdWithoutText : ThemeId -> Task<Theme option>
|
||||
|
||||
/// Save a theme (insert or update)
|
||||
abstract member save : Theme -> Task<unit>
|
||||
|
||||
|
||||
/// Functions to manipulate theme assets
|
||||
type IThemeAssetData =
|
||||
|
||||
/// Retrieve all theme assets (excluding data)
|
||||
abstract member all : unit -> Task<ThemeAsset list>
|
||||
|
||||
/// Delete all theme assets for the given theme
|
||||
abstract member deleteByTheme : ThemeId -> Task<unit>
|
||||
|
||||
/// Find a theme asset by its ID
|
||||
abstract member findById : ThemeAssetId -> Task<ThemeAsset option>
|
||||
|
||||
/// Find all assets for the given theme (excludes data)
|
||||
abstract member findByTheme : ThemeId -> Task<ThemeAsset list>
|
||||
|
||||
/// Find all assets for the given theme (includes data)
|
||||
abstract member findByThemeWithData : ThemeId -> Task<ThemeAsset list>
|
||||
|
||||
/// Save a theme asset (insert or update)
|
||||
abstract member save : ThemeAsset -> Task<unit>
|
||||
|
||||
|
||||
/// Functions to manipulate web logs
|
||||
type IWebLogData =
|
||||
|
||||
/// Add a web log
|
||||
abstract member add : WebLog -> Task<unit>
|
||||
|
||||
/// Retrieve all web logs
|
||||
abstract member all : unit -> Task<WebLog list>
|
||||
|
||||
/// Delete a web log, including categories, tag mappings, posts/comments, and pages
|
||||
abstract member delete : WebLogId -> Task<unit>
|
||||
|
||||
/// Find a web log by its host (URL base)
|
||||
abstract member findByHost : string -> Task<WebLog option>
|
||||
|
||||
/// Find a web log by its ID
|
||||
abstract member findById : WebLogId -> Task<WebLog option>
|
||||
|
||||
/// Update RSS options for a web log
|
||||
abstract member updateRssOptions : WebLog -> Task<unit>
|
||||
|
||||
/// Update web log settings (from the settings page)
|
||||
abstract member updateSettings : WebLog -> Task<unit>
|
||||
|
||||
|
||||
/// Functions to manipulate web log users
|
||||
type IWebLogUserData =
|
||||
|
||||
/// Add a web log user
|
||||
abstract member add : WebLogUser -> Task<unit>
|
||||
|
||||
/// Find a web log user by their e-mail address
|
||||
abstract member findByEmail : email : string -> WebLogId -> Task<WebLogUser option>
|
||||
|
||||
/// Find a web log user by their ID
|
||||
abstract member findById : WebLogUserId -> WebLogId -> Task<WebLogUser option>
|
||||
|
||||
/// Find all web log users for the given web log
|
||||
abstract member findByWebLog : WebLogId -> Task<WebLogUser list>
|
||||
|
||||
/// Get a user ID -> name dictionary for the given user IDs
|
||||
abstract member findNames : WebLogId -> WebLogUserId list -> Task<MetaItem list>
|
||||
|
||||
/// Restore users from a backup
|
||||
abstract member restore : WebLogUser list -> Task<unit>
|
||||
|
||||
/// Update a web log user
|
||||
abstract member update : WebLogUser -> Task<unit>
|
||||
|
||||
|
||||
/// Data interface required for a myWebLog data implementation
|
||||
type IData =
|
||||
|
||||
/// Category data functions
|
||||
abstract member Category : ICategoryData
|
||||
|
||||
/// Page data functions
|
||||
abstract member Page : IPageData
|
||||
|
||||
/// Post data functions
|
||||
abstract member Post : IPostData
|
||||
|
||||
/// Tag map data functions
|
||||
abstract member TagMap : ITagMapData
|
||||
|
||||
/// Theme data functions
|
||||
abstract member Theme : IThemeData
|
||||
|
||||
/// Theme asset data functions
|
||||
abstract member ThemeAsset : IThemeAssetData
|
||||
|
||||
/// Web log data functions
|
||||
abstract member WebLog : IWebLogData
|
||||
|
||||
/// Web log user data functions
|
||||
abstract member WebLogUser : IWebLogUserData
|
||||
|
||||
/// Do any required start up data checks
|
||||
abstract member startUp : unit -> Task<unit>
|
||||
|
30
src/MyWebLog.Data/MyWebLog.Data.fsproj
Normal file
30
src/MyWebLog.Data/MyWebLog.Data.fsproj
Normal file
|
@ -0,0 +1,30 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MyWebLog.Domain\MyWebLog.Domain.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="6.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" />
|
||||
<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" />
|
||||
<PackageReference Include="RethinkDb.Driver.FSharp" Version="0.9.0-beta-05" />
|
||||
<PackageReference Update="FSharp.Core" Version="6.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="Converters.fs" />
|
||||
<Compile Include="Interfaces.fs" />
|
||||
<Compile Include="Utils.fs" />
|
||||
<Compile Include="RethinkDbData.fs" />
|
||||
<Compile Include="SQLiteData.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
885
src/MyWebLog.Data/RethinkDbData.fs
Normal file
885
src/MyWebLog.Data/RethinkDbData.fs
Normal file
|
@ -0,0 +1,885 @@
|
|||
namespace MyWebLog.Data
|
||||
|
||||
open System.Threading.Tasks
|
||||
open MyWebLog
|
||||
open RethinkDb.Driver
|
||||
|
||||
/// Functions to assist with retrieving data
|
||||
[<AutoOpen>]
|
||||
module private RethinkHelpers =
|
||||
|
||||
/// Table names
|
||||
[<RequireQualifiedAccess>]
|
||||
module 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 tag map table
|
||||
let TagMap = "TagMap"
|
||||
|
||||
/// The theme table
|
||||
let Theme = "Theme"
|
||||
|
||||
/// The theme asset table
|
||||
let ThemeAsset = "ThemeAsset"
|
||||
|
||||
/// 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; TagMap; Theme; ThemeAsset; WebLog; WebLogUser ]
|
||||
|
||||
/// A list of all tables with a webLogId field
|
||||
let allForWebLog = [ Comment; Post; Category; TagMap; Page; WebLogUser ]
|
||||
|
||||
|
||||
/// 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 : Net.IConnection -> Task<'T option>) =
|
||||
fun conn -> backgroundTask {
|
||||
match! f conn with Some it when (prop it) = webLogId -> return Some it | _ -> return None
|
||||
}
|
||||
|
||||
/// Get the first item from a list, or None if the list is empty
|
||||
let tryFirst<'T> (f : Net.IConnection -> Task<'T list>) =
|
||||
fun conn -> backgroundTask {
|
||||
let! results = f conn
|
||||
return results |> List.tryHead
|
||||
}
|
||||
|
||||
/// Cast a strongly-typed list to an object list
|
||||
let objList<'T> (objects : 'T list) = objects |> List.map (fun it -> it :> obj)
|
||||
|
||||
|
||||
open Microsoft.Extensions.Logging
|
||||
open MyWebLog.ViewModels
|
||||
open RethinkDb.Driver.FSharp
|
||||
|
||||
/// RethinkDB implementation of data functions for myWebLog
|
||||
type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<RethinkDbData>) =
|
||||
|
||||
/// Match theme asset IDs by their prefix (the theme ID)
|
||||
let matchAssetByThemeId themeId =
|
||||
let keyPrefix = $"^{ThemeId.toString themeId}/"
|
||||
fun (row : Ast.ReqlExpr) -> row["id"].Match keyPrefix :> obj
|
||||
|
||||
/// Ensure field indexes exist, as well as special indexes for selected tables
|
||||
let ensureIndexes table fields = backgroundTask {
|
||||
let! indexes = rethink<string list> { withTable table; indexList; result; withRetryOnce conn }
|
||||
for field in fields do
|
||||
if not (indexes |> List.contains field) then
|
||||
log.LogInformation $"Creating index {table}.{field}..."
|
||||
do! rethink { withTable table; indexCreate field; write; withRetryOnce; ignoreResult conn }
|
||||
// Post and page need index by web log ID and permalink
|
||||
if [ Table.Page; Table.Post ] |> List.contains table then
|
||||
if not (indexes |> List.contains "permalink") then
|
||||
log.LogInformation $"Creating index {table}.permalink..."
|
||||
do! rethink {
|
||||
withTable table
|
||||
indexCreate "permalink" (fun row -> r.Array (row["webLogId"], row["permalink"].Downcase ()) :> obj)
|
||||
write; withRetryOnce; ignoreResult conn
|
||||
}
|
||||
// Prior permalinks are searched when a post or page permalink do not match the current URL
|
||||
if not (indexes |> List.contains "priorPermalinks") then
|
||||
log.LogInformation $"Creating index {table}.priorPermalinks..."
|
||||
do! rethink {
|
||||
withTable table
|
||||
indexCreate "priorPermalinks" (fun row -> row["priorPermalinks"].Downcase () :> obj) [ Multi ]
|
||||
write; withRetryOnce; ignoreResult conn
|
||||
}
|
||||
// Post needs indexes by category and tag (used for counting and retrieving posts)
|
||||
if Table.Post = table then
|
||||
for idx in [ "categoryIds"; "tags" ] do
|
||||
if not (List.contains idx indexes) then
|
||||
log.LogInformation $"Creating index {table}.{idx}..."
|
||||
do! rethink {
|
||||
withTable table
|
||||
indexCreate idx [ Multi ]
|
||||
write; withRetryOnce; ignoreResult conn
|
||||
}
|
||||
// Tag mapping needs an index by web log ID and both tag and URL values
|
||||
if Table.TagMap = table then
|
||||
if not (indexes |> List.contains "webLogAndTag") then
|
||||
log.LogInformation $"Creating index {table}.webLogAndTag..."
|
||||
do! rethink {
|
||||
withTable table
|
||||
indexCreate "webLogAndTag" (fun row -> r.Array (row["webLogId"], row["tag"]) :> obj)
|
||||
write; withRetryOnce; ignoreResult conn
|
||||
}
|
||||
if not (indexes |> List.contains "webLogAndUrl") then
|
||||
log.LogInformation $"Creating index {table}.webLogAndUrl..."
|
||||
do! rethink {
|
||||
withTable table
|
||||
indexCreate "webLogAndUrl" (fun row -> r.Array (row["webLogId"], row["urlValue"]) :> obj)
|
||||
write; withRetryOnce; ignoreResult conn
|
||||
}
|
||||
// Users log on with e-mail
|
||||
if Table.WebLogUser = table && not (indexes |> List.contains "logOn") then
|
||||
log.LogInformation $"Creating index {table}.logOn..."
|
||||
do! rethink {
|
||||
withTable table
|
||||
indexCreate "logOn" (fun row -> r.Array (row["webLogId"], row["userName"]) :> obj)
|
||||
write; withRetryOnce; ignoreResult conn
|
||||
}
|
||||
}
|
||||
|
||||
/// The batch size for restoration methods
|
||||
let restoreBatchSize = 100
|
||||
|
||||
/// The connection for this instance
|
||||
member _.Conn = conn
|
||||
|
||||
interface IData with
|
||||
|
||||
member _.Category = {
|
||||
new ICategoryData with
|
||||
|
||||
member _.add cat = rethink {
|
||||
withTable Table.Category
|
||||
insert cat
|
||||
write; withRetryDefault; ignoreResult conn
|
||||
}
|
||||
|
||||
member _.countAll webLogId = rethink<int> {
|
||||
withTable Table.Category
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
count
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
|
||||
member _.countTopLevel webLogId = rethink<int> {
|
||||
withTable Table.Category
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
filter "parentId" None
|
||||
count
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
|
||||
member _.findAllForView webLogId = backgroundTask {
|
||||
let! cats = rethink<Category list> {
|
||||
withTable Table.Category
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
orderByFunc (fun it -> it["name"].Downcase () :> obj)
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
let ordered = Utils.orderByHierarchy cats None None []
|
||||
let! counts =
|
||||
ordered
|
||||
|> Seq.map (fun it -> backgroundTask {
|
||||
// Parent category post counts include posts in subcategories
|
||||
let catIds =
|
||||
ordered
|
||||
|> Seq.filter (fun cat -> cat.parentNames |> Array.contains it.name)
|
||||
|> Seq.map (fun cat -> cat.id :> obj)
|
||||
|> Seq.append (Seq.singleton it.id)
|
||||
|> List.ofSeq
|
||||
let! count = rethink<int> {
|
||||
withTable Table.Post
|
||||
getAll catIds "categoryIds"
|
||||
filter "status" Published
|
||||
distinct
|
||||
count
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
return it.id, count
|
||||
})
|
||||
|> Task.WhenAll
|
||||
return
|
||||
ordered
|
||||
|> Seq.map (fun cat ->
|
||||
{ cat with
|
||||
postCount = counts
|
||||
|> Array.tryFind (fun c -> fst c = cat.id)
|
||||
|> Option.map snd
|
||||
|> Option.defaultValue 0
|
||||
})
|
||||
|> Array.ofSeq
|
||||
}
|
||||
|
||||
member _.findById catId webLogId =
|
||||
rethink<Category> {
|
||||
withTable Table.Category
|
||||
get catId
|
||||
resultOption; withRetryOptionDefault
|
||||
}
|
||||
|> verifyWebLog webLogId (fun c -> c.webLogId) <| conn
|
||||
|
||||
member _.findByWebLog webLogId = rethink<Category list> {
|
||||
withTable Table.Category
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
|
||||
member this.delete catId webLogId = backgroundTask {
|
||||
match! this.findById catId webLogId with
|
||||
| Some _ ->
|
||||
// Delete the category off all posts where it is assigned
|
||||
do! rethink {
|
||||
withTable Table.Post
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
filter (fun row -> row["categoryIds"].Contains catId :> obj)
|
||||
update (fun row -> r.HashMap ("categoryIds", r.Array(row["categoryIds"]).Remove catId) :> obj)
|
||||
write; withRetryDefault; ignoreResult conn
|
||||
}
|
||||
// Delete the category itself
|
||||
do! rethink {
|
||||
withTable Table.Category
|
||||
get catId
|
||||
delete
|
||||
write; withRetryDefault; ignoreResult conn
|
||||
}
|
||||
return true
|
||||
| None -> return false
|
||||
}
|
||||
|
||||
member _.restore cats = backgroundTask {
|
||||
for batch in cats |> List.chunkBySize restoreBatchSize do
|
||||
do! rethink {
|
||||
withTable Table.Category
|
||||
insert batch
|
||||
write; withRetryOnce; ignoreResult conn
|
||||
}
|
||||
}
|
||||
|
||||
member _.update cat = rethink {
|
||||
withTable Table.Category
|
||||
get cat.id
|
||||
update [ "name", cat.name :> obj
|
||||
"slug", cat.slug
|
||||
"description", cat.description
|
||||
"parentId", cat.parentId
|
||||
]
|
||||
write; withRetryDefault; ignoreResult conn
|
||||
}
|
||||
}
|
||||
|
||||
member _.Page = {
|
||||
new IPageData with
|
||||
|
||||
member _.add page = rethink {
|
||||
withTable Table.Page
|
||||
insert page
|
||||
write; withRetryDefault; ignoreResult conn
|
||||
}
|
||||
|
||||
member _.all webLogId = rethink<Page list> {
|
||||
withTable Table.Page
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
without [ "text"; "metadata"; "revisions"; "priorPermalinks" ]
|
||||
orderByFunc (fun row -> row["title"].Downcase () :> obj)
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
|
||||
member _.countAll webLogId = rethink<int> {
|
||||
withTable Table.Page
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
count
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
|
||||
member _.countListed webLogId = rethink<int> {
|
||||
withTable Table.Page
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
filter "showInPageList" true
|
||||
count
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
|
||||
member _.delete pageId webLogId = backgroundTask {
|
||||
let! result = rethink<Model.Result> {
|
||||
withTable Table.Page
|
||||
getAll [ pageId ]
|
||||
filter (fun row -> row["webLogId"].Eq webLogId :> obj)
|
||||
delete
|
||||
write; withRetryDefault conn
|
||||
}
|
||||
return result.Deleted > 0UL
|
||||
}
|
||||
|
||||
member _.findById pageId webLogId =
|
||||
rethink<Page> {
|
||||
withTable Table.Page
|
||||
get pageId
|
||||
without [ "priorPermalinks"; "revisions" ]
|
||||
resultOption; withRetryOptionDefault
|
||||
}
|
||||
|> verifyWebLog webLogId (fun it -> it.webLogId) <| conn
|
||||
|
||||
member _.findByPermalink permalink webLogId =
|
||||
rethink<Page list> {
|
||||
withTable Table.Page
|
||||
getAll [ r.Array (webLogId, permalink) ] (nameof permalink)
|
||||
without [ "priorPermalinks"; "revisions" ]
|
||||
limit 1
|
||||
result; withRetryDefault
|
||||
}
|
||||
|> tryFirst <| conn
|
||||
|
||||
member _.findCurrentPermalink permalinks webLogId = backgroundTask {
|
||||
let! result =
|
||||
(rethink<Page list> {
|
||||
withTable Table.Page
|
||||
getAll (objList permalinks) "priorPermalinks"
|
||||
filter "webLogId" webLogId
|
||||
without [ "revisions"; "text" ]
|
||||
limit 1
|
||||
result; withRetryDefault
|
||||
}
|
||||
|> tryFirst) conn
|
||||
return result |> Option.map (fun pg -> pg.permalink)
|
||||
}
|
||||
|
||||
member _.findFullById pageId webLogId =
|
||||
rethink<Page> {
|
||||
withTable Table.Page
|
||||
get pageId
|
||||
resultOption; withRetryOptionDefault
|
||||
}
|
||||
|> verifyWebLog webLogId (fun it -> it.webLogId) <| conn
|
||||
|
||||
member _.findFullByWebLog webLogId = rethink<Page> {
|
||||
withTable Table.Page
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
resultCursor; withRetryCursorDefault; toList conn
|
||||
}
|
||||
|
||||
member _.findListed webLogId = rethink<Page list> {
|
||||
withTable Table.Page
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
filter [ "showInPageList", true :> obj ]
|
||||
without [ "text"; "priorPermalinks"; "revisions" ]
|
||||
orderBy "title"
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
|
||||
member _.findPageOfPages webLogId pageNbr = rethink<Page list> {
|
||||
withTable Table.Page
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
without [ "metadata"; "priorPermalinks"; "revisions" ]
|
||||
orderByFunc (fun row -> row["title"].Downcase ())
|
||||
skip ((pageNbr - 1) * 25)
|
||||
limit 25
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
|
||||
member _.restore pages = backgroundTask {
|
||||
for batch in pages |> List.chunkBySize restoreBatchSize do
|
||||
do! rethink {
|
||||
withTable Table.Page
|
||||
insert batch
|
||||
write; withRetryOnce; ignoreResult conn
|
||||
}
|
||||
}
|
||||
|
||||
member _.update page = rethink {
|
||||
withTable Table.Page
|
||||
get page.id
|
||||
update [
|
||||
"title", page.title :> obj
|
||||
"permalink", page.permalink
|
||||
"updatedOn", page.updatedOn
|
||||
"showInPageList", page.showInPageList
|
||||
"template", page.template
|
||||
"text", page.text
|
||||
"priorPermalinks", page.priorPermalinks
|
||||
"metadata", page.metadata
|
||||
"revisions", page.revisions
|
||||
]
|
||||
write; withRetryDefault; ignoreResult conn
|
||||
}
|
||||
|
||||
member this.updatePriorPermalinks pageId webLogId permalinks = backgroundTask {
|
||||
match! this.findById pageId webLogId with
|
||||
| Some _ ->
|
||||
do! rethink {
|
||||
withTable Table.Page
|
||||
get pageId
|
||||
update [ "priorPermalinks", permalinks :> obj ]
|
||||
write; withRetryDefault; ignoreResult conn
|
||||
}
|
||||
return true
|
||||
| None -> return false
|
||||
}
|
||||
}
|
||||
|
||||
member _.Post = {
|
||||
new IPostData with
|
||||
|
||||
member _.add post = rethink {
|
||||
withTable Table.Post
|
||||
insert post
|
||||
write; withRetryDefault; ignoreResult conn
|
||||
}
|
||||
|
||||
member _.countByStatus status webLogId = rethink<int> {
|
||||
withTable Table.Post
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
filter "status" status
|
||||
count
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
|
||||
member _.delete postId webLogId = backgroundTask {
|
||||
let! result = rethink<Model.Result> {
|
||||
withTable Table.Post
|
||||
getAll [ postId ]
|
||||
filter (fun row -> row["webLogId"].Eq webLogId :> obj)
|
||||
delete
|
||||
write; withRetryDefault conn
|
||||
}
|
||||
return result.Deleted > 0UL
|
||||
}
|
||||
|
||||
member _.findByPermalink permalink webLogId =
|
||||
rethink<Post list> {
|
||||
withTable Table.Post
|
||||
getAll [ r.Array (webLogId, permalink) ] (nameof permalink)
|
||||
without [ "priorPermalinks"; "revisions" ]
|
||||
limit 1
|
||||
result; withRetryDefault
|
||||
}
|
||||
|> tryFirst <| conn
|
||||
|
||||
member _.findFullById postId webLogId =
|
||||
rethink<Post> {
|
||||
withTable Table.Post
|
||||
get postId
|
||||
resultOption; withRetryOptionDefault
|
||||
}
|
||||
|> verifyWebLog webLogId (fun p -> p.webLogId) <| conn
|
||||
|
||||
member _.findCurrentPermalink permalinks webLogId = backgroundTask {
|
||||
let! result =
|
||||
(rethink<Post list> {
|
||||
withTable Table.Post
|
||||
getAll (objList permalinks) "priorPermalinks"
|
||||
filter "webLogId" webLogId
|
||||
without [ "revisions"; "text" ]
|
||||
limit 1
|
||||
result; withRetryDefault
|
||||
}
|
||||
|> tryFirst) conn
|
||||
return result |> Option.map (fun post -> post.permalink)
|
||||
}
|
||||
|
||||
member _.findFullByWebLog webLogId = rethink<Post> {
|
||||
withTable Table.Post
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
resultCursor; withRetryCursorDefault; toList conn
|
||||
}
|
||||
|
||||
member _.findPageOfCategorizedPosts webLogId categoryIds pageNbr postsPerPage = rethink<Post list> {
|
||||
withTable Table.Post
|
||||
getAll (objList categoryIds) "categoryIds"
|
||||
filter "webLogId" webLogId
|
||||
filter "status" Published
|
||||
without [ "priorPermalinks"; "revisions" ]
|
||||
distinct
|
||||
orderByDescending "publishedOn"
|
||||
skip ((pageNbr - 1) * postsPerPage)
|
||||
limit (postsPerPage + 1)
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
|
||||
member _.findPageOfPosts webLogId pageNbr postsPerPage = rethink<Post list> {
|
||||
withTable Table.Post
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
without [ "priorPermalinks"; "revisions" ]
|
||||
orderByFuncDescending (fun row -> row["publishedOn"].Default_ "updatedOn" :> obj)
|
||||
skip ((pageNbr - 1) * postsPerPage)
|
||||
limit (postsPerPage + 1)
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
|
||||
member _.findPageOfPublishedPosts webLogId pageNbr postsPerPage = rethink<Post list> {
|
||||
withTable Table.Post
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
filter "status" Published
|
||||
without [ "priorPermalinks"; "revisions" ]
|
||||
orderByDescending "publishedOn"
|
||||
skip ((pageNbr - 1) * postsPerPage)
|
||||
limit (postsPerPage + 1)
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
|
||||
member _.findPageOfTaggedPosts webLogId tag pageNbr postsPerPage = rethink<Post list> {
|
||||
withTable Table.Post
|
||||
getAll [ tag ] "tags"
|
||||
filter "webLogId" webLogId
|
||||
filter "status" Published
|
||||
without [ "priorPermalinks"; "revisions" ]
|
||||
orderByDescending "publishedOn"
|
||||
skip ((pageNbr - 1) * postsPerPage)
|
||||
limit (postsPerPage + 1)
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
|
||||
member _.findSurroundingPosts webLogId publishedOn = backgroundTask {
|
||||
let! older =
|
||||
rethink<Post list> {
|
||||
withTable Table.Post
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
filter (fun row -> row["publishedOn"].Lt publishedOn :> obj)
|
||||
without [ "priorPermalinks"; "revisions" ]
|
||||
orderByDescending "publishedOn"
|
||||
limit 1
|
||||
result; withRetryDefault
|
||||
}
|
||||
|> tryFirst <| conn
|
||||
let! newer =
|
||||
rethink<Post list> {
|
||||
withTable Table.Post
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
filter (fun row -> row["publishedOn"].Gt publishedOn :> obj)
|
||||
without [ "priorPermalinks"; "revisions" ]
|
||||
orderBy "publishedOn"
|
||||
limit 1
|
||||
result; withRetryDefault
|
||||
}
|
||||
|> tryFirst <| conn
|
||||
return older, newer
|
||||
}
|
||||
|
||||
member _.restore pages = backgroundTask {
|
||||
for batch in pages |> List.chunkBySize restoreBatchSize do
|
||||
do! rethink {
|
||||
withTable Table.Post
|
||||
insert batch
|
||||
write; withRetryOnce; ignoreResult conn
|
||||
}
|
||||
}
|
||||
|
||||
member _.update post = rethink {
|
||||
withTable Table.Post
|
||||
get post.id
|
||||
replace post
|
||||
write; withRetryDefault; ignoreResult conn
|
||||
}
|
||||
|
||||
member _.updatePriorPermalinks postId webLogId permalinks = backgroundTask {
|
||||
match! (
|
||||
rethink<Post> {
|
||||
withTable Table.Post
|
||||
get postId
|
||||
without [ "revisions"; "priorPermalinks" ]
|
||||
resultOption; withRetryOptionDefault
|
||||
}
|
||||
|> verifyWebLog webLogId (fun p -> p.webLogId)) conn with
|
||||
| Some _ ->
|
||||
do! rethink {
|
||||
withTable Table.Post
|
||||
get postId
|
||||
update [ "priorPermalinks", permalinks :> obj ]
|
||||
write; withRetryDefault; ignoreResult conn
|
||||
}
|
||||
return true
|
||||
| None -> return false
|
||||
}
|
||||
}
|
||||
|
||||
member _.TagMap = {
|
||||
new ITagMapData with
|
||||
|
||||
member _.delete tagMapId webLogId = backgroundTask {
|
||||
let! result = rethink<Model.Result> {
|
||||
withTable Table.TagMap
|
||||
getAll [ tagMapId ]
|
||||
filter (fun row -> row["webLogId"].Eq webLogId :> obj)
|
||||
delete
|
||||
write; withRetryDefault conn
|
||||
}
|
||||
return result.Deleted > 0UL
|
||||
}
|
||||
|
||||
member _.findById tagMapId webLogId =
|
||||
rethink<TagMap> {
|
||||
withTable Table.TagMap
|
||||
get tagMapId
|
||||
resultOption; withRetryOptionDefault
|
||||
}
|
||||
|> verifyWebLog webLogId (fun tm -> tm.webLogId) <| conn
|
||||
|
||||
member _.findByUrlValue urlValue webLogId =
|
||||
rethink<TagMap list> {
|
||||
withTable Table.TagMap
|
||||
getAll [ r.Array (webLogId, urlValue) ] "webLogAndUrl"
|
||||
limit 1
|
||||
result; withRetryDefault
|
||||
}
|
||||
|> tryFirst <| conn
|
||||
|
||||
member _.findByWebLog webLogId = rethink<TagMap list> {
|
||||
withTable Table.TagMap
|
||||
between (r.Array (webLogId, r.Minval ())) (r.Array (webLogId, r.Maxval ())) [ Index "webLogAndTag" ]
|
||||
orderBy "tag"
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
|
||||
member _.findMappingForTags tags webLogId = rethink<TagMap list> {
|
||||
withTable Table.TagMap
|
||||
getAll (tags |> List.map (fun tag -> r.Array (webLogId, tag) :> obj)) "webLogAndTag"
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
|
||||
member _.restore tagMaps = backgroundTask {
|
||||
for batch in tagMaps |> List.chunkBySize restoreBatchSize do
|
||||
do! rethink {
|
||||
withTable Table.TagMap
|
||||
insert batch
|
||||
write; withRetryOnce; ignoreResult conn
|
||||
}
|
||||
}
|
||||
|
||||
member _.save tagMap = rethink {
|
||||
withTable Table.TagMap
|
||||
get tagMap.id
|
||||
replace tagMap
|
||||
write; withRetryDefault; ignoreResult conn
|
||||
}
|
||||
}
|
||||
|
||||
member _.Theme = {
|
||||
new IThemeData with
|
||||
|
||||
member _.all () = rethink<Theme list> {
|
||||
withTable Table.Theme
|
||||
filter (fun row -> row["id"].Ne "admin" :> obj)
|
||||
without [ "templates" ]
|
||||
orderBy "id"
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
|
||||
member _.findById themeId = rethink<Theme> {
|
||||
withTable Table.Theme
|
||||
get themeId
|
||||
resultOption; withRetryOptionDefault conn
|
||||
}
|
||||
|
||||
member _.findByIdWithoutText themeId = rethink<Theme> {
|
||||
withTable Table.Theme
|
||||
get themeId
|
||||
merge (fun row -> r.HashMap ("templates", row["templates"].Without [| "text" |]))
|
||||
resultOption; withRetryOptionDefault conn
|
||||
}
|
||||
|
||||
member _.save theme = rethink {
|
||||
withTable Table.Theme
|
||||
get theme.id
|
||||
replace theme
|
||||
write; withRetryDefault; ignoreResult conn
|
||||
}
|
||||
}
|
||||
|
||||
member _.ThemeAsset = {
|
||||
new IThemeAssetData with
|
||||
|
||||
member _.all () = rethink<ThemeAsset list> {
|
||||
withTable Table.ThemeAsset
|
||||
without [ "data" ]
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
|
||||
member _.deleteByTheme themeId = rethink {
|
||||
withTable Table.ThemeAsset
|
||||
filter (matchAssetByThemeId themeId)
|
||||
delete
|
||||
write; withRetryDefault; ignoreResult conn
|
||||
}
|
||||
|
||||
member _.findById assetId = rethink<ThemeAsset> {
|
||||
withTable Table.ThemeAsset
|
||||
get assetId
|
||||
resultOption; withRetryOptionDefault conn
|
||||
}
|
||||
|
||||
member _.findByTheme themeId = rethink<ThemeAsset list> {
|
||||
withTable Table.ThemeAsset
|
||||
filter (matchAssetByThemeId themeId)
|
||||
without [ "data" ]
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
|
||||
member _.findByThemeWithData themeId = rethink<ThemeAsset> {
|
||||
withTable Table.ThemeAsset
|
||||
filter (matchAssetByThemeId themeId)
|
||||
resultCursor; withRetryCursorDefault; toList conn
|
||||
}
|
||||
|
||||
member _.save asset = rethink {
|
||||
withTable Table.ThemeAsset
|
||||
get asset.id
|
||||
replace asset
|
||||
write; withRetryDefault; ignoreResult conn
|
||||
}
|
||||
}
|
||||
|
||||
member _.WebLog = {
|
||||
new IWebLogData with
|
||||
|
||||
member _.add webLog = rethink {
|
||||
withTable Table.WebLog
|
||||
insert webLog
|
||||
write; withRetryOnce; ignoreResult conn
|
||||
}
|
||||
|
||||
member _.all () = rethink<WebLog list> {
|
||||
withTable Table.WebLog
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
|
||||
member _.delete webLogId = backgroundTask {
|
||||
for table in Table.allForWebLog do
|
||||
do! rethink {
|
||||
withTable table
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
delete
|
||||
write; withRetryOnce; ignoreResult conn
|
||||
}
|
||||
do! rethink {
|
||||
withTable Table.WebLog
|
||||
get webLogId
|
||||
delete
|
||||
write; withRetryOnce; ignoreResult conn
|
||||
}
|
||||
}
|
||||
|
||||
member _.findByHost url =
|
||||
rethink<WebLog list> {
|
||||
withTable Table.WebLog
|
||||
getAll [ url ] "urlBase"
|
||||
limit 1
|
||||
result; withRetryDefault
|
||||
}
|
||||
|> tryFirst <| conn
|
||||
|
||||
member _.findById webLogId = rethink<WebLog> {
|
||||
withTable Table.WebLog
|
||||
get webLogId
|
||||
resultOption; withRetryOptionDefault conn
|
||||
}
|
||||
|
||||
member _.updateRssOptions webLog = rethink {
|
||||
withTable Table.WebLog
|
||||
get webLog.id
|
||||
update [ "rss", webLog.rss :> obj ]
|
||||
write; withRetryDefault; ignoreResult conn
|
||||
}
|
||||
|
||||
member _.updateSettings webLog = rethink {
|
||||
withTable Table.WebLog
|
||||
get webLog.id
|
||||
update [
|
||||
"name", webLog.name :> obj
|
||||
"subtitle", webLog.subtitle
|
||||
"defaultPage", webLog.defaultPage
|
||||
"postsPerPage", webLog.postsPerPage
|
||||
"timeZone", webLog.timeZone
|
||||
"themePath", webLog.themePath
|
||||
"autoHtmx", webLog.autoHtmx
|
||||
]
|
||||
write; withRetryDefault; ignoreResult conn
|
||||
}
|
||||
}
|
||||
|
||||
member _.WebLogUser = {
|
||||
new IWebLogUserData with
|
||||
|
||||
member _.add user = rethink {
|
||||
withTable Table.WebLogUser
|
||||
insert user
|
||||
write; withRetryDefault; ignoreResult conn
|
||||
}
|
||||
|
||||
member _.findByEmail email webLogId =
|
||||
rethink<WebLogUser list> {
|
||||
withTable Table.WebLogUser
|
||||
getAll [ r.Array (webLogId, email) ] "logOn"
|
||||
limit 1
|
||||
result; withRetryDefault
|
||||
}
|
||||
|> tryFirst <| conn
|
||||
|
||||
member _.findById userId webLogId =
|
||||
rethink<WebLogUser> {
|
||||
withTable Table.WebLogUser
|
||||
get userId
|
||||
resultOption; withRetryOptionDefault
|
||||
}
|
||||
|> verifyWebLog webLogId (fun u -> u.webLogId) <| conn
|
||||
|
||||
member _.findByWebLog webLogId = rethink<WebLogUser list> {
|
||||
withTable Table.WebLogUser
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
|
||||
member _.findNames webLogId userIds = backgroundTask {
|
||||
let! users = rethink<WebLogUser list> {
|
||||
withTable Table.WebLogUser
|
||||
getAll (objList userIds)
|
||||
filter "webLogId" webLogId
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
return
|
||||
users
|
||||
|> List.map (fun u -> { name = WebLogUserId.toString u.id; value = WebLogUser.displayName u })
|
||||
}
|
||||
|
||||
member _.restore users = backgroundTask {
|
||||
for batch in users |> List.chunkBySize restoreBatchSize do
|
||||
do! rethink {
|
||||
withTable Table.WebLogUser
|
||||
insert batch
|
||||
write; withRetryOnce; ignoreResult conn
|
||||
}
|
||||
}
|
||||
|
||||
member _.update user = rethink {
|
||||
withTable Table.WebLogUser
|
||||
get user.id
|
||||
update [
|
||||
"firstName", user.firstName :> obj
|
||||
"lastName", user.lastName
|
||||
"preferredName", user.preferredName
|
||||
"passwordHash", user.passwordHash
|
||||
"salt", user.salt
|
||||
]
|
||||
write; withRetryDefault; ignoreResult conn
|
||||
}
|
||||
}
|
||||
|
||||
member _.startUp () = backgroundTask {
|
||||
let! dbs = rethink<string list> { dbList; result; withRetryOnce conn }
|
||||
if not (dbs |> List.contains config.Database) then
|
||||
log.LogInformation $"Creating database {config.Database}..."
|
||||
do! rethink { dbCreate config.Database; write; withRetryOnce; ignoreResult conn }
|
||||
|
||||
let! tables = rethink<string list> { tableList; result; withRetryOnce conn }
|
||||
for tbl in Table.all do
|
||||
if not (tables |> List.contains tbl) then
|
||||
log.LogInformation $"Creating table {tbl}..."
|
||||
do! rethink { tableCreate tbl; write; withRetryOnce; ignoreResult conn }
|
||||
|
||||
do! ensureIndexes Table.Category [ "webLogId" ]
|
||||
do! ensureIndexes Table.Comment [ "postId" ]
|
||||
do! ensureIndexes Table.Page [ "webLogId"; "authorId" ]
|
||||
do! ensureIndexes Table.Post [ "webLogId"; "authorId" ]
|
||||
do! ensureIndexes Table.TagMap []
|
||||
do! ensureIndexes Table.WebLog [ "urlBase" ]
|
||||
do! ensureIndexes Table.WebLogUser [ "webLogId" ]
|
||||
}
|
2017
src/MyWebLog.Data/SQLiteData.fs
Normal file
2017
src/MyWebLog.Data/SQLiteData.fs
Normal file
File diff suppressed because it is too large
Load Diff
22
src/MyWebLog.Data/Utils.fs
Normal file
22
src/MyWebLog.Data/Utils.fs
Normal file
|
@ -0,0 +1,22 @@
|
|||
/// Utility functions for manipulating data
|
||||
[<RequireQualifiedAccess>]
|
||||
module internal MyWebLog.Data.Utils
|
||||
|
||||
open MyWebLog
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
/// Create a category hierarchy from the given list of categories
|
||||
let rec orderByHierarchy (cats : Category list) parentId slugBase parentNames = seq {
|
||||
for cat in cats |> List.filter (fun c -> c.parentId = parentId) do
|
||||
let fullSlug = (match slugBase with Some it -> $"{it}/" | None -> "") + cat.slug
|
||||
{ id = CategoryId.toString cat.id
|
||||
slug = fullSlug
|
||||
name = cat.name
|
||||
description = cat.description
|
||||
parentNames = Array.ofList parentNames
|
||||
// Post counts are filled on a second pass
|
||||
postCount = 0
|
||||
}
|
||||
yield! orderByHierarchy cats (Some cat.id) (Some fullSlug) ([ cat.name ] |> List.append parentNames)
|
||||
}
|
||||
|
427
src/MyWebLog.Domain/DataTypes.fs
Normal file
427
src/MyWebLog.Domain/DataTypes.fs
Normal file
|
@ -0,0 +1,427 @@
|
|||
namespace MyWebLog
|
||||
|
||||
open System
|
||||
open MyWebLog
|
||||
|
||||
/// A category under which a post may be identified
|
||||
[<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
|
||||
|
||||
/// Metadata for this page
|
||||
metadata : MetaItem list
|
||||
|
||||
/// Permalinks at which this page may have been previously served (useful for migrated content)
|
||||
priorPermalinks : Permalink 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 = ""
|
||||
metadata = []
|
||||
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 template to use in displaying the post
|
||||
template : string option
|
||||
|
||||
/// 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
|
||||
|
||||
/// Metadata for the post
|
||||
metadata : MetaItem 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 = ""
|
||||
template = None
|
||||
categoryIds = []
|
||||
tags = []
|
||||
metadata = []
|
||||
priorPermalinks = []
|
||||
revisions = []
|
||||
}
|
||||
|
||||
|
||||
/// A mapping between a tag and its URL value, used to translate restricted characters (ex. "#1" -> "number-1")
|
||||
type TagMap =
|
||||
{ /// The ID of this tag mapping
|
||||
id : TagMapId
|
||||
|
||||
/// The ID of the web log to which this tag mapping belongs
|
||||
webLogId : WebLogId
|
||||
|
||||
/// The tag which should be mapped to a different value in links
|
||||
tag : string
|
||||
|
||||
/// The value by which the tag should be linked
|
||||
urlValue : string
|
||||
}
|
||||
|
||||
/// Functions to support tag mappings
|
||||
module TagMap =
|
||||
|
||||
/// An empty tag mapping
|
||||
let empty =
|
||||
{ id = TagMapId.empty
|
||||
webLogId = WebLogId.empty
|
||||
tag = ""
|
||||
urlValue = ""
|
||||
}
|
||||
|
||||
|
||||
/// A theme
|
||||
type Theme =
|
||||
{ /// The ID / path of the theme
|
||||
id : ThemeId
|
||||
|
||||
/// A long name of the theme
|
||||
name : string
|
||||
|
||||
/// The version of the theme
|
||||
version : string
|
||||
|
||||
/// The templates for this theme
|
||||
templates: ThemeTemplate list
|
||||
}
|
||||
|
||||
/// Functions to support themes
|
||||
module Theme =
|
||||
|
||||
/// An empty theme
|
||||
let empty =
|
||||
{ id = ThemeId ""
|
||||
name = ""
|
||||
version = ""
|
||||
templates = []
|
||||
}
|
||||
|
||||
|
||||
/// A theme asset (a file served as part of a theme, at /themes/[theme]/[asset-path])
|
||||
type ThemeAsset =
|
||||
{
|
||||
/// The ID of the asset (consists of theme and path)
|
||||
id : ThemeAssetId
|
||||
|
||||
/// The updated date (set from the file date from the ZIP archive)
|
||||
updatedOn : DateTime
|
||||
|
||||
/// The data for the asset
|
||||
data : byte[]
|
||||
}
|
||||
|
||||
|
||||
/// 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 /themes)
|
||||
themePath : string
|
||||
|
||||
/// The URL base
|
||||
urlBase : string
|
||||
|
||||
/// The time zone in which dates/times should be displayed
|
||||
timeZone : string
|
||||
|
||||
/// The RSS options for this web log
|
||||
rss : RssOptions
|
||||
|
||||
/// Whether to automatically load htmx
|
||||
autoHtmx : bool
|
||||
}
|
||||
|
||||
/// Functions to support web logs
|
||||
module WebLog =
|
||||
|
||||
/// An empty web log
|
||||
let empty =
|
||||
{ id = WebLogId.empty
|
||||
name = ""
|
||||
subtitle = None
|
||||
defaultPage = ""
|
||||
postsPerPage = 10
|
||||
themePath = "default"
|
||||
urlBase = ""
|
||||
timeZone = ""
|
||||
rss = RssOptions.empty
|
||||
autoHtmx = false
|
||||
}
|
||||
|
||||
/// Get the host (including scheme) and extra path from the URL base
|
||||
let hostAndPath webLog =
|
||||
let scheme = webLog.urlBase.Split "://"
|
||||
let host = scheme[1].Split "/"
|
||||
$"{scheme[0]}://{host[0]}", if host.Length > 1 then $"""/{String.Join ("/", host |> Array.skip 1)}""" else ""
|
||||
|
||||
/// Generate an absolute URL for the given link
|
||||
let absoluteUrl webLog permalink =
|
||||
$"{webLog.urlBase}/{Permalink.toString permalink}"
|
||||
|
||||
/// Generate a relative URL for the given link
|
||||
let relativeUrl webLog permalink =
|
||||
let _, leadPath = hostAndPath webLog
|
||||
$"{leadPath}/{Permalink.toString permalink}"
|
||||
|
||||
/// Convert a UTC date/time to the web log's local date/time
|
||||
let localTime webLog (date : DateTime) =
|
||||
TimeZoneInfo.ConvertTimeFromUtc
|
||||
(DateTime (date.Ticks, DateTimeKind.Utc), TimeZoneInfo.FindSystemTimeZoneById webLog.timeZone)
|
||||
|
||||
/// Convert a date/time in the web log's local date/time to UTC
|
||||
let utcTime webLog (date : DateTime) =
|
||||
TimeZoneInfo.ConvertTimeToUtc
|
||||
(DateTime (date.Ticks, DateTimeKind.Unspecified), TimeZoneInfo.FindSystemTimeZoneById webLog.timeZone)
|
||||
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// Get the user's displayed name
|
||||
let displayName user =
|
||||
let name =
|
||||
seq { match user.preferredName with "" -> user.firstName | n -> n; " "; user.lastName }
|
||||
|> Seq.reduce (+)
|
||||
name.Trim ()
|
20
src/MyWebLog.Domain/MyWebLog.Domain.fsproj
Normal file
20
src/MyWebLog.Domain/MyWebLog.Domain.fsproj
Normal file
|
@ -0,0 +1,20 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="SupportTypes.fs" />
|
||||
<Compile Include="DataTypes.fs" />
|
||||
<Compile Include="ViewModels.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Markdig" Version="0.30.2" />
|
||||
<PackageReference Update="FSharp.Core" Version="6.0.5" />
|
||||
<PackageReference Include="Markdown.ColorCode" Version="1.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
483
src/MyWebLog.Domain/SupportTypes.fs
Normal file
483
src/MyWebLog.Domain/SupportTypes.fs
Normal file
|
@ -0,0 +1,483 @@
|
|||
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('+', '-').Substring (0, 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
|
||||
|
||||
/// Functions to support post comment statuses
|
||||
module CommentStatus =
|
||||
|
||||
/// Convert a comment status to a string
|
||||
let toString = function Approved -> "Approved" | Pending -> "Pending" | Spam -> "Spam"
|
||||
|
||||
/// Parse a string into a comment status
|
||||
let parse value =
|
||||
match value with
|
||||
| "Approved" -> Approved
|
||||
| "Pending" -> Pending
|
||||
| "Spam" -> Spam
|
||||
| it -> invalidOp $"{it} is not a valid post status"
|
||||
|
||||
|
||||
open Markdig
|
||||
open Markdown.ColorCode
|
||||
|
||||
/// Types of markup text
|
||||
type MarkupText =
|
||||
/// Markdown text
|
||||
| Markdown of string
|
||||
/// HTML text
|
||||
| Html of string
|
||||
|
||||
/// Functions to support markup text
|
||||
module MarkupText =
|
||||
|
||||
/// Pipeline with most extensions enabled
|
||||
let private _pipeline = MarkdownPipelineBuilder().UseSmartyPants().UseAdvancedExtensions().UseColorCode().Build ()
|
||||
|
||||
/// Get the source type for the markup text
|
||||
let sourceType = function Markdown _ -> "Markdown" | Html _ -> "HTML"
|
||||
|
||||
/// Get the raw text, regardless of type
|
||||
let text = function Markdown text -> text | Html text -> text
|
||||
|
||||
/// Get the string representation of the markup text
|
||||
let toString it = $"{sourceType it}: {text it}"
|
||||
|
||||
/// Get the HTML representation of the markup text
|
||||
let toHtml = function Markdown text -> Markdown.ToHtml (text, _pipeline) | Html text -> text
|
||||
|
||||
/// Parse a string into a MarkupText instance
|
||||
let parse (it : string) =
|
||||
match it with
|
||||
| text when text.StartsWith "Markdown: " -> Markdown (text.Substring 10)
|
||||
| text when text.StartsWith "HTML: " -> Html (text.Substring 6)
|
||||
| text -> invalidOp $"Cannot derive type of text ({text})"
|
||||
|
||||
|
||||
/// An item of metadata
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type MetaItem =
|
||||
{ /// The name of the metadata value
|
||||
name : string
|
||||
|
||||
/// The metadata value
|
||||
value : string
|
||||
}
|
||||
|
||||
/// Functions to support metadata items
|
||||
module MetaItem =
|
||||
|
||||
/// An empty metadata item
|
||||
let empty =
|
||||
{ name = ""; value = "" }
|
||||
|
||||
|
||||
/// A revision of a page or post
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Revision =
|
||||
{ /// When this revision was saved
|
||||
asOf : DateTime
|
||||
|
||||
/// The text of the revision
|
||||
text : MarkupText
|
||||
}
|
||||
|
||||
/// Functions to support revisions
|
||||
module Revision =
|
||||
|
||||
/// An empty revision
|
||||
let empty =
|
||||
{ asOf = DateTime.UtcNow
|
||||
text = Html ""
|
||||
}
|
||||
|
||||
|
||||
/// 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
|
||||
|
||||
/// Functions to support post statuses
|
||||
module PostStatus =
|
||||
|
||||
/// Convert a post status to a string
|
||||
let toString = function Draft -> "Draft" | Published -> "Published"
|
||||
|
||||
/// Parse a string into a post status
|
||||
let parse value =
|
||||
match value with
|
||||
| "Draft" -> Draft
|
||||
| "Published" -> Published
|
||||
| it -> invalidOp $"{it} is not a valid post status"
|
||||
|
||||
|
||||
/// 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 custom feed
|
||||
type CustomFeedId = CustomFeedId of string
|
||||
|
||||
/// Functions to support custom feed IDs
|
||||
module CustomFeedId =
|
||||
|
||||
/// An empty custom feed ID
|
||||
let empty = CustomFeedId ""
|
||||
|
||||
/// Convert a custom feed ID to a string
|
||||
let toString = function CustomFeedId pi -> pi
|
||||
|
||||
/// Create a new custom feed ID
|
||||
let create () = CustomFeedId (newId ())
|
||||
|
||||
|
||||
/// The source for a custom feed
|
||||
type CustomFeedSource =
|
||||
/// A feed based on a particular category
|
||||
| Category of CategoryId
|
||||
/// A feed based on a particular tag
|
||||
| Tag of string
|
||||
|
||||
/// Functions to support feed sources
|
||||
module CustomFeedSource =
|
||||
/// Create a string version of a feed source
|
||||
let toString : CustomFeedSource -> string =
|
||||
function
|
||||
| Category (CategoryId catId) -> $"category:{catId}"
|
||||
| Tag tag -> $"tag:{tag}"
|
||||
|
||||
/// Parse a feed source from its string version
|
||||
let parse : string -> CustomFeedSource =
|
||||
let value (it : string) = it.Split(":").[1]
|
||||
function
|
||||
| source when source.StartsWith "category:" -> (value >> CategoryId >> Category) source
|
||||
| source when source.StartsWith "tag:" -> (value >> Tag) source
|
||||
| source -> invalidArg "feedSource" $"{source} is not a valid feed source"
|
||||
|
||||
|
||||
/// Valid values for the iTunes explicit rating
|
||||
type ExplicitRating =
|
||||
| Yes
|
||||
| No
|
||||
| Clean
|
||||
|
||||
/// Functions to support iTunes explicit ratings
|
||||
module ExplicitRating =
|
||||
/// Convert an explicit rating to a string
|
||||
let toString : ExplicitRating -> string =
|
||||
function
|
||||
| Yes -> "yes"
|
||||
| No -> "no"
|
||||
| Clean -> "clean"
|
||||
|
||||
/// Parse a string into an explicit rating
|
||||
let parse : string -> ExplicitRating =
|
||||
function
|
||||
| "yes" -> Yes
|
||||
| "no" -> No
|
||||
| "clean" -> Clean
|
||||
| x -> raise (invalidArg "rating" $"{x} is not a valid explicit rating")
|
||||
|
||||
|
||||
/// Options for a feed that describes a podcast
|
||||
type PodcastOptions =
|
||||
{ /// The title of the podcast
|
||||
title : string
|
||||
|
||||
/// A subtitle for the podcast
|
||||
subtitle : string option
|
||||
|
||||
/// The number of items in the podcast feed
|
||||
itemsInFeed : int
|
||||
|
||||
/// A summary of the podcast (iTunes field)
|
||||
summary : string
|
||||
|
||||
/// The display name of the podcast author (iTunes field)
|
||||
displayedAuthor : string
|
||||
|
||||
/// The e-mail address of the user who registered the podcast at iTunes
|
||||
email : string
|
||||
|
||||
/// The link to the image for the podcast
|
||||
imageUrl : Permalink
|
||||
|
||||
/// The category from iTunes under which this podcast is categorized
|
||||
iTunesCategory : string
|
||||
|
||||
/// A further refinement of the categorization of this podcast (iTunes field / values)
|
||||
iTunesSubcategory : string option
|
||||
|
||||
/// The explictness rating (iTunes field)
|
||||
explicit : ExplicitRating
|
||||
|
||||
/// The default media type for files in this podcast
|
||||
defaultMediaType : string option
|
||||
|
||||
/// The base URL for relative URL media files for this podcast (optional; defaults to web log base)
|
||||
mediaBaseUrl : string option
|
||||
}
|
||||
|
||||
|
||||
/// A custom feed
|
||||
type CustomFeed =
|
||||
{ /// The ID of the custom feed
|
||||
id : CustomFeedId
|
||||
|
||||
/// The source for the custom feed
|
||||
source : CustomFeedSource
|
||||
|
||||
/// The path for the custom feed
|
||||
path : Permalink
|
||||
|
||||
/// Podcast options, if the feed defines a podcast
|
||||
podcast : PodcastOptions option
|
||||
}
|
||||
|
||||
/// Functions to support custom feeds
|
||||
module CustomFeed =
|
||||
|
||||
/// An empty custom feed
|
||||
let empty =
|
||||
{ id = CustomFeedId ""
|
||||
source = Category (CategoryId "")
|
||||
path = Permalink ""
|
||||
podcast = None
|
||||
}
|
||||
|
||||
|
||||
/// Really Simple Syndication (RSS) options for this web log
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type RssOptions =
|
||||
{ /// Whether the site feed of posts is enabled
|
||||
feedEnabled : bool
|
||||
|
||||
/// The name of the file generated for the site feed
|
||||
feedName : string
|
||||
|
||||
/// Override the "posts per page" setting for the site feed
|
||||
itemsInFeed : int option
|
||||
|
||||
/// Whether feeds are enabled for all categories
|
||||
categoryEnabled : bool
|
||||
|
||||
/// Whether feeds are enabled for all tags
|
||||
tagEnabled : bool
|
||||
|
||||
/// A copyright string to be placed in all feeds
|
||||
copyright : string option
|
||||
|
||||
/// Custom feeds for this web log
|
||||
customFeeds: CustomFeed list
|
||||
}
|
||||
|
||||
/// Functions to support RSS options
|
||||
module RssOptions =
|
||||
|
||||
/// An empty set of RSS options
|
||||
let empty =
|
||||
{ feedEnabled = true
|
||||
feedName = "feed.xml"
|
||||
itemsInFeed = None
|
||||
categoryEnabled = true
|
||||
tagEnabled = true
|
||||
copyright = None
|
||||
customFeeds = []
|
||||
}
|
||||
|
||||
|
||||
/// An identifier for a tag mapping
|
||||
type TagMapId = TagMapId of string
|
||||
|
||||
/// Functions to support tag mapping IDs
|
||||
module TagMapId =
|
||||
|
||||
/// An empty tag mapping ID
|
||||
let empty = TagMapId ""
|
||||
|
||||
/// Convert a tag mapping ID to a string
|
||||
let toString = function TagMapId tmi -> tmi
|
||||
|
||||
/// Create a new tag mapping ID
|
||||
let create () = TagMapId (newId ())
|
||||
|
||||
|
||||
/// An identifier for a theme (represents its path)
|
||||
type ThemeId = ThemeId of string
|
||||
|
||||
/// Functions to support theme IDs
|
||||
module ThemeId =
|
||||
let toString = function ThemeId ti -> ti
|
||||
|
||||
|
||||
/// An identifier for a theme asset
|
||||
type ThemeAssetId = ThemeAssetId of ThemeId * string
|
||||
|
||||
/// Functions to support theme asset IDs
|
||||
module ThemeAssetId =
|
||||
|
||||
/// Convert a theme asset ID into a path string
|
||||
let toString = function ThemeAssetId (ThemeId theme, asset) -> $"{theme}/{asset}"
|
||||
|
||||
/// Convert a string into a theme asset ID
|
||||
let ofString (it : string) =
|
||||
let themeIdx = it.IndexOf "/"
|
||||
ThemeAssetId (ThemeId it[..(themeIdx - 1)], it[(themeIdx + 1)..])
|
||||
|
||||
|
||||
/// A template for a theme
|
||||
type ThemeTemplate =
|
||||
{ /// The name of the template
|
||||
name : string
|
||||
|
||||
/// The text of the template
|
||||
text : string
|
||||
}
|
||||
|
||||
|
||||
/// 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
|
||||
|
||||
/// Functions to support authorization levels
|
||||
module AuthorizationLevel =
|
||||
|
||||
/// Convert an authorization level to a string
|
||||
let toString = function Administrator -> "Administrator" | User -> "User"
|
||||
|
||||
/// Parse a string into an authorization level
|
||||
let parse value =
|
||||
match value with
|
||||
| "Administrator" -> Administrator
|
||||
| "User" -> User
|
||||
| it -> invalidOp $"{it} is not a valid authorization level"
|
||||
|
||||
|
||||
/// 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 ())
|
||||
|
||||
|
740
src/MyWebLog.Domain/ViewModels.fs
Normal file
740
src/MyWebLog.Domain/ViewModels.fs
Normal file
|
@ -0,0 +1,740 @@
|
|||
namespace MyWebLog.ViewModels
|
||||
|
||||
open System
|
||||
open MyWebLog
|
||||
|
||||
/// Helper functions for view models
|
||||
[<AutoOpen>]
|
||||
module private Helpers =
|
||||
|
||||
/// Create a string option if a string is blank
|
||||
let noneIfBlank (it : string) =
|
||||
match it.Trim () with "" -> None | trimmed -> Some trimmed
|
||||
|
||||
|
||||
/// Details about a category, used to display category lists
|
||||
[<NoComparison; NoEquality>]
|
||||
type DisplayCategory =
|
||||
{ /// The ID of the category
|
||||
id : string
|
||||
|
||||
/// The slug for the category
|
||||
slug : string
|
||||
|
||||
/// The name of the category
|
||||
name : string
|
||||
|
||||
/// A description of the category
|
||||
description : string option
|
||||
|
||||
/// The parent category names for this (sub)category
|
||||
parentNames : string[]
|
||||
|
||||
/// The number of posts in this category
|
||||
postCount : int
|
||||
}
|
||||
|
||||
|
||||
/// A display version of a custom feed definition
|
||||
type DisplayCustomFeed =
|
||||
{ /// The ID of the custom feed
|
||||
id : string
|
||||
|
||||
/// The source of the custom feed
|
||||
source : string
|
||||
|
||||
/// The relative path at which the custom feed is served
|
||||
path : string
|
||||
|
||||
/// Whether this custom feed is for a podcast
|
||||
isPodcast : bool
|
||||
}
|
||||
|
||||
/// Create a display version from a custom feed
|
||||
static member fromFeed (cats : DisplayCategory[]) (feed : CustomFeed) : DisplayCustomFeed =
|
||||
let source =
|
||||
match feed.source with
|
||||
| Category (CategoryId catId) -> $"Category: {(cats |> Array.find (fun cat -> cat.id = catId)).name}"
|
||||
| Tag tag -> $"Tag: {tag}"
|
||||
{ id = CustomFeedId.toString feed.id
|
||||
source = source
|
||||
path = Permalink.toString feed.path
|
||||
isPodcast = Option.isSome feed.podcast
|
||||
}
|
||||
|
||||
|
||||
/// Details about a page used to display page lists
|
||||
[<NoComparison; NoEquality>]
|
||||
type DisplayPage =
|
||||
{ /// The ID of this page
|
||||
id : string
|
||||
|
||||
/// The title of the page
|
||||
title : string
|
||||
|
||||
/// The link at which this page is displayed
|
||||
permalink : string
|
||||
|
||||
/// 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
|
||||
|
||||
/// Is this the default page?
|
||||
isDefault : bool
|
||||
|
||||
/// The text of the page
|
||||
text : string
|
||||
|
||||
/// The metadata for the page
|
||||
metadata : MetaItem list
|
||||
}
|
||||
|
||||
/// Create a minimal display page (no text or metadata) from a database page
|
||||
static member fromPageMinimal webLog (page : Page) =
|
||||
let pageId = PageId.toString page.id
|
||||
{ id = pageId
|
||||
title = page.title
|
||||
permalink = Permalink.toString page.permalink
|
||||
publishedOn = page.publishedOn
|
||||
updatedOn = page.updatedOn
|
||||
showInPageList = page.showInPageList
|
||||
isDefault = pageId = webLog.defaultPage
|
||||
text = ""
|
||||
metadata = []
|
||||
}
|
||||
|
||||
/// Create a display page from a database page
|
||||
static member fromPage webLog (page : Page) =
|
||||
let _, extra = WebLog.hostAndPath webLog
|
||||
let pageId = PageId.toString page.id
|
||||
{ id = pageId
|
||||
title = page.title
|
||||
permalink = Permalink.toString page.permalink
|
||||
publishedOn = page.publishedOn
|
||||
updatedOn = page.updatedOn
|
||||
showInPageList = page.showInPageList
|
||||
isDefault = pageId = webLog.defaultPage
|
||||
text = if extra = "" then page.text else page.text.Replace ("href=\"/", $"href=\"{extra}/")
|
||||
metadata = page.metadata
|
||||
}
|
||||
|
||||
|
||||
/// The model used to display the admin dashboard
|
||||
[<NoComparison; NoEquality>]
|
||||
type DashboardModel =
|
||||
{ /// The number of published posts
|
||||
posts : int
|
||||
|
||||
/// The number of post drafts
|
||||
drafts : int
|
||||
|
||||
/// The number of pages
|
||||
pages : int
|
||||
|
||||
/// The number of pages in the page list
|
||||
listedPages : int
|
||||
|
||||
/// The number of categories
|
||||
categories : int
|
||||
|
||||
/// The top-level categories
|
||||
topLevelCategories : int
|
||||
}
|
||||
|
||||
|
||||
/// View model for editing categories
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type EditCategoryModel =
|
||||
{ /// The ID of the category being edited
|
||||
categoryId : string
|
||||
|
||||
/// The name of the category
|
||||
name : string
|
||||
|
||||
/// The category's URL slug
|
||||
slug : string
|
||||
|
||||
/// A description of the category (optional)
|
||||
description : string
|
||||
|
||||
/// The ID of the category for which this is a subcategory (optional)
|
||||
parentId : string
|
||||
}
|
||||
|
||||
/// Create an edit model from an existing category
|
||||
static member fromCategory (cat : Category) =
|
||||
{ categoryId = CategoryId.toString cat.id
|
||||
name = cat.name
|
||||
slug = cat.slug
|
||||
description = defaultArg cat.description ""
|
||||
parentId = cat.parentId |> Option.map CategoryId.toString |> Option.defaultValue ""
|
||||
}
|
||||
|
||||
|
||||
/// View model to edit a custom RSS feed
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type EditCustomFeedModel =
|
||||
{ /// The ID of the feed being editing
|
||||
id : string
|
||||
|
||||
/// The type of source for this feed ("category" or "tag")
|
||||
sourceType : string
|
||||
|
||||
/// The category ID or tag on which this feed is based
|
||||
sourceValue : string
|
||||
|
||||
/// The relative path at which this feed is served
|
||||
path : string
|
||||
|
||||
/// Whether this feed defines a podcast
|
||||
isPodcast : bool
|
||||
|
||||
/// The title of the podcast
|
||||
title : string
|
||||
|
||||
/// A subtitle for the podcast
|
||||
subtitle : string
|
||||
|
||||
/// The number of items in the podcast feed
|
||||
itemsInFeed : int
|
||||
|
||||
/// A summary of the podcast (iTunes field)
|
||||
summary : string
|
||||
|
||||
/// The display name of the podcast author (iTunes field)
|
||||
displayedAuthor : string
|
||||
|
||||
/// The e-mail address of the user who registered the podcast at iTunes
|
||||
email : string
|
||||
|
||||
/// The link to the image for the podcast
|
||||
imageUrl : string
|
||||
|
||||
/// The category from iTunes under which this podcast is categorized
|
||||
itunesCategory : string
|
||||
|
||||
/// A further refinement of the categorization of this podcast (iTunes field / values)
|
||||
itunesSubcategory : string
|
||||
|
||||
/// The explictness rating (iTunes field)
|
||||
explicit : string
|
||||
|
||||
/// The default media type for files in this podcast
|
||||
defaultMediaType : string
|
||||
|
||||
/// The base URL for relative URL media files for this podcast (optional; defaults to web log base)
|
||||
mediaBaseUrl : string
|
||||
}
|
||||
|
||||
/// An empty custom feed model
|
||||
static member empty =
|
||||
{ id = ""
|
||||
sourceType = "category"
|
||||
sourceValue = ""
|
||||
path = ""
|
||||
isPodcast = false
|
||||
title = ""
|
||||
subtitle = ""
|
||||
itemsInFeed = 25
|
||||
summary = ""
|
||||
displayedAuthor = ""
|
||||
email = ""
|
||||
imageUrl = ""
|
||||
itunesCategory = ""
|
||||
itunesSubcategory = ""
|
||||
explicit = "no"
|
||||
defaultMediaType = "audio/mpeg"
|
||||
mediaBaseUrl = ""
|
||||
}
|
||||
|
||||
/// Create a model from a custom feed
|
||||
static member fromFeed (feed : CustomFeed) =
|
||||
let rss =
|
||||
{ EditCustomFeedModel.empty with
|
||||
id = CustomFeedId.toString feed.id
|
||||
sourceType = match feed.source with Category _ -> "category" | Tag _ -> "tag"
|
||||
sourceValue = match feed.source with Category (CategoryId catId) -> catId | Tag tag -> tag
|
||||
path = Permalink.toString feed.path
|
||||
}
|
||||
match feed.podcast with
|
||||
| Some p ->
|
||||
{ rss with
|
||||
isPodcast = true
|
||||
title = p.title
|
||||
subtitle = defaultArg p.subtitle ""
|
||||
itemsInFeed = p.itemsInFeed
|
||||
summary = p.summary
|
||||
displayedAuthor = p.displayedAuthor
|
||||
email = p.email
|
||||
imageUrl = Permalink.toString p.imageUrl
|
||||
itunesCategory = p.iTunesCategory
|
||||
itunesSubcategory = defaultArg p.iTunesSubcategory ""
|
||||
explicit = ExplicitRating.toString p.explicit
|
||||
defaultMediaType = defaultArg p.defaultMediaType ""
|
||||
mediaBaseUrl = defaultArg p.mediaBaseUrl ""
|
||||
}
|
||||
| None -> rss
|
||||
|
||||
/// Update a feed with values from this model
|
||||
member this.updateFeed (feed : CustomFeed) =
|
||||
{ feed with
|
||||
source = if this.sourceType = "tag" then Tag this.sourceValue else Category (CategoryId this.sourceValue)
|
||||
path = Permalink this.path
|
||||
podcast =
|
||||
if this.isPodcast then
|
||||
Some {
|
||||
title = this.title
|
||||
subtitle = noneIfBlank this.subtitle
|
||||
itemsInFeed = this.itemsInFeed
|
||||
summary = this.summary
|
||||
displayedAuthor = this.displayedAuthor
|
||||
email = this.email
|
||||
imageUrl = Permalink this.imageUrl
|
||||
iTunesCategory = this.itunesCategory
|
||||
iTunesSubcategory = noneIfBlank this.itunesSubcategory
|
||||
explicit = ExplicitRating.parse this.explicit
|
||||
defaultMediaType = noneIfBlank this.defaultMediaType
|
||||
mediaBaseUrl = noneIfBlank this.mediaBaseUrl
|
||||
}
|
||||
else
|
||||
None
|
||||
}
|
||||
|
||||
/// View model to edit a page
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type EditPageModel =
|
||||
{ /// The ID of the page being edited
|
||||
pageId : string
|
||||
|
||||
/// The title of the page
|
||||
title : string
|
||||
|
||||
/// The permalink for the page
|
||||
permalink : string
|
||||
|
||||
/// The template to use to display the page
|
||||
template : string
|
||||
|
||||
/// Whether this page is shown in the page list
|
||||
isShownInPageList : bool
|
||||
|
||||
/// The source format for the text
|
||||
source : string
|
||||
|
||||
/// The text of the page
|
||||
text : string
|
||||
|
||||
/// Names of metadata items
|
||||
metaNames : string[]
|
||||
|
||||
/// Values of metadata items
|
||||
metaValues : string[]
|
||||
}
|
||||
|
||||
/// Create an edit model from an existing page
|
||||
static member fromPage (page : Page) =
|
||||
let latest =
|
||||
match page.revisions |> List.sortByDescending (fun r -> r.asOf) |> List.tryHead with
|
||||
| Some rev -> rev
|
||||
| None -> Revision.empty
|
||||
let page = if page.metadata |> List.isEmpty then { page with metadata = [ MetaItem.empty ] } else page
|
||||
{ pageId = PageId.toString page.id
|
||||
title = page.title
|
||||
permalink = Permalink.toString page.permalink
|
||||
template = defaultArg page.template ""
|
||||
isShownInPageList = page.showInPageList
|
||||
source = MarkupText.sourceType latest.text
|
||||
text = MarkupText.text latest.text
|
||||
metaNames = page.metadata |> List.map (fun m -> m.name) |> Array.ofList
|
||||
metaValues = page.metadata |> List.map (fun m -> m.value) |> Array.ofList
|
||||
}
|
||||
|
||||
|
||||
/// View model to edit a post
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type EditPostModel =
|
||||
{ /// The ID of the post being edited
|
||||
postId : string
|
||||
|
||||
/// The title of the post
|
||||
title : string
|
||||
|
||||
/// The permalink for the post
|
||||
permalink : string
|
||||
|
||||
/// The source format for the text
|
||||
source : string
|
||||
|
||||
/// The text of the post
|
||||
text : string
|
||||
|
||||
/// The tags for the post
|
||||
tags : string
|
||||
|
||||
/// The template used to display the post
|
||||
template : string
|
||||
|
||||
/// The category IDs for the post
|
||||
categoryIds : string[]
|
||||
|
||||
/// The post status
|
||||
status : string
|
||||
|
||||
/// Whether this post should be published
|
||||
doPublish : bool
|
||||
|
||||
/// Names of metadata items
|
||||
metaNames : string[]
|
||||
|
||||
/// Values of metadata items
|
||||
metaValues : string[]
|
||||
|
||||
/// Whether to override the published date/time
|
||||
setPublished : bool
|
||||
|
||||
/// The published date/time to override
|
||||
pubOverride : Nullable<DateTime>
|
||||
|
||||
/// Whether all revisions should be purged and the override date set as the updated date as well
|
||||
setUpdated : bool
|
||||
}
|
||||
/// Create an edit model from an existing past
|
||||
static member fromPost webLog (post : Post) =
|
||||
let latest =
|
||||
match post.revisions |> List.sortByDescending (fun r -> r.asOf) |> List.tryHead with
|
||||
| Some rev -> rev
|
||||
| None -> Revision.empty
|
||||
let post = if post.metadata |> List.isEmpty then { post with metadata = [ MetaItem.empty ] } else post
|
||||
{ postId = PostId.toString post.id
|
||||
title = post.title
|
||||
permalink = Permalink.toString post.permalink
|
||||
source = MarkupText.sourceType latest.text
|
||||
text = MarkupText.text latest.text
|
||||
tags = String.Join (", ", post.tags)
|
||||
template = defaultArg post.template ""
|
||||
categoryIds = post.categoryIds |> List.map CategoryId.toString |> Array.ofList
|
||||
status = PostStatus.toString post.status
|
||||
doPublish = false
|
||||
metaNames = post.metadata |> List.map (fun m -> m.name) |> Array.ofList
|
||||
metaValues = post.metadata |> List.map (fun m -> m.value) |> Array.ofList
|
||||
setPublished = false
|
||||
pubOverride = post.publishedOn |> Option.map (WebLog.localTime webLog) |> Option.toNullable
|
||||
setUpdated = false
|
||||
}
|
||||
|
||||
|
||||
/// View model to edit RSS settings
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type EditRssModel =
|
||||
{ /// Whether the site feed of posts is enabled
|
||||
feedEnabled : bool
|
||||
|
||||
/// The name of the file generated for the site feed
|
||||
feedName : string
|
||||
|
||||
/// Override the "posts per page" setting for the site feed
|
||||
itemsInFeed : int
|
||||
|
||||
/// Whether feeds are enabled for all categories
|
||||
categoryEnabled : bool
|
||||
|
||||
/// Whether feeds are enabled for all tags
|
||||
tagEnabled : bool
|
||||
|
||||
/// A copyright string to be placed in all feeds
|
||||
copyright : string
|
||||
}
|
||||
|
||||
/// Create an edit model from a set of RSS options
|
||||
static member fromRssOptions (rss : RssOptions) =
|
||||
{ feedEnabled = rss.feedEnabled
|
||||
feedName = rss.feedName
|
||||
itemsInFeed = defaultArg rss.itemsInFeed 0
|
||||
categoryEnabled = rss.categoryEnabled
|
||||
tagEnabled = rss.tagEnabled
|
||||
copyright = defaultArg rss.copyright ""
|
||||
}
|
||||
|
||||
/// Update RSS options from values in this mode
|
||||
member this.updateOptions (rss : RssOptions) =
|
||||
{ rss with
|
||||
feedEnabled = this.feedEnabled
|
||||
feedName = this.feedName
|
||||
itemsInFeed = if this.itemsInFeed = 0 then None else Some this.itemsInFeed
|
||||
categoryEnabled = this.categoryEnabled
|
||||
tagEnabled = this.tagEnabled
|
||||
copyright = noneIfBlank this.copyright
|
||||
}
|
||||
|
||||
|
||||
/// View model to edit a tag mapping
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type EditTagMapModel =
|
||||
{ /// The ID of the tag mapping being edited
|
||||
id : string
|
||||
|
||||
/// The tag being mapped to a different link value
|
||||
tag : string
|
||||
|
||||
/// The link value for the tag
|
||||
urlValue : string
|
||||
}
|
||||
|
||||
/// Whether this is a new tag mapping
|
||||
member this.isNew = this.id = "new"
|
||||
|
||||
/// Create an edit model from the tag mapping
|
||||
static member fromMapping (tagMap : TagMap) : EditTagMapModel =
|
||||
{ id = TagMapId.toString tagMap.id
|
||||
tag = tagMap.tag
|
||||
urlValue = tagMap.urlValue
|
||||
}
|
||||
|
||||
|
||||
/// View model to edit a user
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type EditUserModel =
|
||||
{ /// The user's first name
|
||||
firstName : string
|
||||
|
||||
/// The user's last name
|
||||
lastName : string
|
||||
|
||||
/// The user's preferred name
|
||||
preferredName : string
|
||||
|
||||
/// A new password for the user
|
||||
newPassword : string
|
||||
|
||||
/// A new password for the user, confirmed
|
||||
newPasswordConfirm : string
|
||||
}
|
||||
/// Create an edit model from a user
|
||||
static member fromUser (user : WebLogUser) =
|
||||
{ firstName = user.firstName
|
||||
lastName = user.lastName
|
||||
preferredName = user.preferredName
|
||||
newPassword = ""
|
||||
newPasswordConfirm = ""
|
||||
}
|
||||
|
||||
|
||||
/// The model to use to allow a user to log on
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type LogOnModel =
|
||||
{ /// The user's e-mail address
|
||||
emailAddress : string
|
||||
|
||||
/// The user's password
|
||||
password : string
|
||||
|
||||
/// Where the user should be redirected once they have logged on
|
||||
returnTo : string option
|
||||
}
|
||||
|
||||
/// An empty log on model
|
||||
static member empty =
|
||||
{ emailAddress = ""; password = ""; returnTo = None }
|
||||
|
||||
|
||||
/// View model to manage permalinks
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type ManagePermalinksModel =
|
||||
{ /// The ID for the entity being edited
|
||||
id : string
|
||||
|
||||
/// The type of entity being edited ("page" or "post")
|
||||
entity : string
|
||||
|
||||
/// The current title of the page or post
|
||||
currentTitle : string
|
||||
|
||||
/// The current permalink of the page or post
|
||||
currentPermalink : string
|
||||
|
||||
/// The prior permalinks for the page or post
|
||||
prior : string[]
|
||||
}
|
||||
|
||||
/// Create a permalink model from a page
|
||||
static member fromPage (pg : Page) =
|
||||
{ id = PageId.toString pg.id
|
||||
entity = "page"
|
||||
currentTitle = pg.title
|
||||
currentPermalink = Permalink.toString pg.permalink
|
||||
prior = pg.priorPermalinks |> List.map Permalink.toString |> Array.ofList
|
||||
}
|
||||
|
||||
/// Create a permalink model from a post
|
||||
static member fromPost (post : Post) =
|
||||
{ id = PostId.toString post.id
|
||||
entity = "post"
|
||||
currentTitle = post.title
|
||||
currentPermalink = Permalink.toString post.permalink
|
||||
prior = post.priorPermalinks |> List.map Permalink.toString |> Array.ofList
|
||||
}
|
||||
|
||||
|
||||
/// View model for posts in a list
|
||||
[<NoComparison; NoEquality>]
|
||||
type PostListItem =
|
||||
{ /// The ID of the post
|
||||
id : string
|
||||
|
||||
/// The ID of the user who authored the post
|
||||
authorId : string
|
||||
|
||||
/// The status of the post
|
||||
status : string
|
||||
|
||||
/// The title of the post
|
||||
title : string
|
||||
|
||||
/// The permalink for the post
|
||||
permalink : string
|
||||
|
||||
/// When this post was published
|
||||
publishedOn : Nullable<DateTime>
|
||||
|
||||
/// When this post was last updated
|
||||
updatedOn : DateTime
|
||||
|
||||
/// The text of the post
|
||||
text : string
|
||||
|
||||
/// The IDs of the categories for this post
|
||||
categoryIds : string list
|
||||
|
||||
/// Tags for the post
|
||||
tags : string list
|
||||
|
||||
/// Metadata for the post
|
||||
metadata : MetaItem list
|
||||
}
|
||||
|
||||
/// Create a post list item from a post
|
||||
static member fromPost (webLog : WebLog) (post : Post) =
|
||||
let _, extra = WebLog.hostAndPath webLog
|
||||
let inTZ = WebLog.localTime webLog
|
||||
{ id = PostId.toString post.id
|
||||
authorId = WebLogUserId.toString post.authorId
|
||||
status = PostStatus.toString post.status
|
||||
title = post.title
|
||||
permalink = Permalink.toString post.permalink
|
||||
publishedOn = post.publishedOn |> Option.map inTZ |> Option.toNullable
|
||||
updatedOn = inTZ post.updatedOn
|
||||
text = if extra = "" then post.text else post.text.Replace ("href=\"/", $"href=\"{extra}/")
|
||||
categoryIds = post.categoryIds |> List.map CategoryId.toString
|
||||
tags = post.tags
|
||||
metadata = post.metadata
|
||||
}
|
||||
|
||||
|
||||
/// View model for displaying posts
|
||||
type PostDisplay =
|
||||
{ /// The posts to be displayed
|
||||
posts : PostListItem[]
|
||||
|
||||
/// Author ID -> name lookup
|
||||
authors : MetaItem list
|
||||
|
||||
/// A subtitle for the page
|
||||
subtitle : string option
|
||||
|
||||
/// The link to view newer (more recent) posts
|
||||
newerLink : string option
|
||||
|
||||
/// The name of the next newer post (single-post only)
|
||||
newerName : string option
|
||||
|
||||
/// The link to view older (less recent) posts
|
||||
olderLink : string option
|
||||
|
||||
/// The name of the next older post (single-post only)
|
||||
olderName : string option
|
||||
}
|
||||
|
||||
|
||||
/// View model for editing web log settings
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type SettingsModel =
|
||||
{ /// The name of the web log
|
||||
name : string
|
||||
|
||||
/// The subtitle of the web log
|
||||
subtitle : string
|
||||
|
||||
/// The default page
|
||||
defaultPage : string
|
||||
|
||||
/// How many posts should appear on index pages
|
||||
postsPerPage : int
|
||||
|
||||
/// The time zone in which dates/times should be displayed
|
||||
timeZone : string
|
||||
|
||||
/// The theme to use to display the web log
|
||||
themePath : string
|
||||
|
||||
/// Whether to automatically load htmx
|
||||
autoHtmx : bool
|
||||
}
|
||||
|
||||
/// Create a settings model from a web log
|
||||
static member fromWebLog (webLog : WebLog) =
|
||||
{ name = webLog.name
|
||||
subtitle = defaultArg webLog.subtitle ""
|
||||
defaultPage = webLog.defaultPage
|
||||
postsPerPage = webLog.postsPerPage
|
||||
timeZone = webLog.timeZone
|
||||
themePath = webLog.themePath
|
||||
autoHtmx = webLog.autoHtmx
|
||||
}
|
||||
|
||||
/// Update a web log with settings from the form
|
||||
member this.update (webLog : WebLog) =
|
||||
{ webLog with
|
||||
name = this.name
|
||||
subtitle = if this.subtitle = "" then None else Some this.subtitle
|
||||
defaultPage = this.defaultPage
|
||||
postsPerPage = this.postsPerPage
|
||||
timeZone = this.timeZone
|
||||
themePath = this.themePath
|
||||
autoHtmx = this.autoHtmx
|
||||
}
|
||||
|
||||
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type UserMessage =
|
||||
{ /// The level of the message
|
||||
level : string
|
||||
|
||||
/// The message
|
||||
message : string
|
||||
|
||||
/// Further details about the message
|
||||
detail : string option
|
||||
}
|
||||
|
||||
/// Functions to support user messages
|
||||
module UserMessage =
|
||||
|
||||
/// An empty user message (use one of the others for pre-filled level)
|
||||
let empty = { level = ""; message = ""; detail = None }
|
||||
|
||||
/// A blank success message
|
||||
let success = { empty with level = "success" }
|
||||
|
||||
/// A blank informational message
|
||||
let info = { empty with level = "primary" }
|
||||
|
||||
/// A blank warning message
|
||||
let warning = { empty with level = "warning" }
|
||||
|
||||
/// A blank error message
|
||||
let error = { empty with level = "danger" }
|
|
@ -1,178 +0,0 @@
|
|||
module Program
|
||||
|
||||
open MyWebLog
|
||||
open MyWebLog.Data.RethinkDB
|
||||
open MyWebLog.Entities
|
||||
open Nancy.Cryptography
|
||||
open Newtonsoft.Json
|
||||
open Newtonsoft.Json.Linq
|
||||
open NodaTime
|
||||
open RethinkDb.Driver
|
||||
open System
|
||||
open System.Linq
|
||||
|
||||
let r = RethinkDB.R
|
||||
|
||||
let appCfg = try AppConfig.FromJson (System.IO.File.ReadAllText "config.json")
|
||||
with ex -> raise <| Exception ("Bad config.json file", ex)
|
||||
let cfg = appCfg.DataConfig
|
||||
// DataConfig.Connect
|
||||
// (JsonConvert.DeserializeObject<DataConfig>("""{ "hostname" : "data01", "authKey" : "1d9a76f8-2d85-4033-be15-1f4313a96bb2", "database" : "myWebLog" }"""))
|
||||
let conn = cfg.Conn
|
||||
let toTicks (dt : DateTime) = Instant.FromDateTimeUtc(dt.ToUniversalTime()).ToUnixTimeTicks ()
|
||||
/// Hash the user's password
|
||||
let pbkdf2 (pw : string) =
|
||||
PassphraseKeyGenerator(pw, appCfg.PasswordSalt, 4096).GetBytes 512
|
||||
|> Seq.fold (fun acc byt -> sprintf "%s%s" acc (byt.ToString "x2")) ""
|
||||
|
||||
let migr8 () =
|
||||
SetUp.startUpCheck cfg
|
||||
|
||||
Console.WriteLine "Migrating web logs..."
|
||||
|
||||
r.Db("MyWebLog").Table(Table.WebLog)
|
||||
.RunCursor<JObject>(conn)
|
||||
|> Seq.iter (fun x ->
|
||||
r.Db("myWebLog").Table(Table.WebLog)
|
||||
.Insert({ Id = string x.["id"]
|
||||
Name = string x.["name"]
|
||||
Subtitle = Some <| string x.["subtitle"]
|
||||
DefaultPage = string x.["defaultPage"]
|
||||
ThemePath = string x.["themePath"]
|
||||
TimeZone = string x.["timeZone"]
|
||||
UrlBase = string x.["urlBase"]
|
||||
PageList = []
|
||||
})
|
||||
.RunResult(conn)
|
||||
|> ignore)
|
||||
|
||||
Console.WriteLine "Migrating users..."
|
||||
|
||||
r.Db("MyWebLog").Table(Table.User)
|
||||
.RunCursor<JObject>(conn)
|
||||
|> Seq.iter (fun x ->
|
||||
r.Db("myWebLog").Table(Table.User)
|
||||
.Insert({ Id = string x.["id"]
|
||||
UserName = string x.["userName"]
|
||||
FirstName = string x.["firstName"]
|
||||
LastName = string x.["lastName"]
|
||||
PreferredName = string x.["preferredName"]
|
||||
PasswordHash = string x.["passwordHash"]
|
||||
Url = Some <| string x.["url"]
|
||||
Authorizations = x.["authorizations"] :?> JArray
|
||||
|> Seq.map (fun y -> { WebLogId = string y.["webLogId"]
|
||||
Level = string y.["level"] })
|
||||
|> Seq.toList
|
||||
})
|
||||
.RunResult(conn)
|
||||
|> ignore)
|
||||
|
||||
Console.WriteLine "Migrating categories..."
|
||||
|
||||
r.Db("MyWebLog").Table(Table.Category)
|
||||
.RunCursor<JObject>(conn)
|
||||
|> Seq.iter (fun x ->
|
||||
r.Db("myWebLog").Table(Table.Category)
|
||||
.Insert({ Id = string x.["id"]
|
||||
WebLogId = string x.["webLogId"]
|
||||
Name = string x.["name"]
|
||||
Slug = string x.["slug"]
|
||||
Description = match String.IsNullOrEmpty(string x.["description"]) with
|
||||
| true -> None
|
||||
| _ -> Some <| string x.["description"]
|
||||
ParentId = match String.IsNullOrEmpty(string x.["parentId"]) with
|
||||
| true -> None
|
||||
| _ -> Some <| string x.["parentId"]
|
||||
Children = x.["children"] :?> JArray
|
||||
|> Seq.map (fun y -> string y)
|
||||
|> Seq.toList
|
||||
})
|
||||
.RunResult(conn)
|
||||
|> ignore)
|
||||
|
||||
Console.WriteLine "Migrating comments..."
|
||||
|
||||
r.Db("MyWebLog").Table(Table.Comment)
|
||||
.RunCursor<JObject>(conn)
|
||||
|> Seq.iter (fun x ->
|
||||
r.Db("myWebLog").Table(Table.Comment)
|
||||
.Insert({ Id = string x.["id"]
|
||||
PostId = string x.["postId"]
|
||||
InReplyToId = match String.IsNullOrEmpty(string x.["inReplyToId"]) with
|
||||
| true -> None
|
||||
| _ -> Some <| string x.["inReplyToId"]
|
||||
Name = string x.["name"]
|
||||
Email = string x.["email"]
|
||||
Url = match String.IsNullOrEmpty(string x.["url"]) with
|
||||
| true -> None
|
||||
| _ -> Some <| string x.["url"]
|
||||
Status = string x.["status"]
|
||||
PostedOn = x.["postedDate"].ToObject<DateTime>() |> toTicks
|
||||
Text = string x.["text"]
|
||||
})
|
||||
.RunResult(conn)
|
||||
|> ignore)
|
||||
|
||||
Console.WriteLine "Migrating pages..."
|
||||
|
||||
r.Db("MyWebLog").Table(Table.Page)
|
||||
.RunCursor<JObject>(conn)
|
||||
|> Seq.iter (fun x ->
|
||||
r.Db("myWebLog").Table(Table.Page)
|
||||
.Insert({ Id = string x.["id"]
|
||||
WebLogId = string x.["webLogId"]
|
||||
AuthorId = string x.["authorId"]
|
||||
Title = string x.["title"]
|
||||
Permalink = string x.["permalink"]
|
||||
PublishedOn = x.["publishedDate"].ToObject<DateTime> () |> toTicks
|
||||
UpdatedOn = x.["lastUpdatedDate"].ToObject<DateTime> () |> toTicks
|
||||
ShowInPageList = x.["showInPageList"].ToObject<bool>()
|
||||
Text = string x.["text"]
|
||||
Revisions = [{ AsOf = x.["lastUpdatedDate"].ToObject<DateTime> () |> toTicks
|
||||
SourceType = RevisionSource.HTML
|
||||
Text = string x.["text"]
|
||||
}]
|
||||
})
|
||||
.RunResult(conn)
|
||||
|> ignore)
|
||||
|
||||
Console.WriteLine "Migrating posts..."
|
||||
|
||||
r.Db("MyWebLog").Table(Table.Post)
|
||||
.RunCursor<JObject>(conn)
|
||||
|> Seq.iter (fun x ->
|
||||
r.Db("myWebLog").Table(Table.Post)
|
||||
.Insert({ Id = string x.["id"]
|
||||
WebLogId = string x.["webLogId"]
|
||||
AuthorId = "9b491a0f-48df-4b7b-8c10-120b5cd02895"
|
||||
Status = string x.["status"]
|
||||
Title = string x.["title"]
|
||||
Permalink = string x.["permalink"]
|
||||
PublishedOn = match x.["publishedDate"] with
|
||||
| null -> int64 0
|
||||
| dt -> dt.ToObject<DateTime> () |> toTicks
|
||||
UpdatedOn = x.["lastUpdatedDate"].ToObject<DateTime> () |> toTicks
|
||||
Revisions = [{ AsOf = x.["lastUpdatedDate"].ToObject<DateTime> ()
|
||||
|> toTicks
|
||||
SourceType = RevisionSource.HTML
|
||||
Text = string x.["text"]
|
||||
}]
|
||||
Text = string x.["text"]
|
||||
Tags = x.["tag"] :?> JArray
|
||||
|> Seq.map (fun y -> string y)
|
||||
|> Seq.toList
|
||||
CategoryIds = x.["category"] :?> JArray
|
||||
|> Seq.map (fun y -> string y)
|
||||
|> Seq.toList
|
||||
PriorPermalinks = []
|
||||
Categories = []
|
||||
Comments = []
|
||||
})
|
||||
.RunResult(conn)
|
||||
|> ignore)
|
||||
|
||||
|
||||
[<EntryPoint>]
|
||||
let main argv =
|
||||
migr8 ()
|
||||
0 // return an integer exit code
|
|
@ -1,33 +0,0 @@
|
|||
{
|
||||
"version": "1.0.0-*",
|
||||
"buildOptions": {
|
||||
"debugType": "portable",
|
||||
"emitEntryPoint": true,
|
||||
"compilerName": "fsc",
|
||||
"compile": {
|
||||
"includeFiles": [
|
||||
"Program.fs"
|
||||
]
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"MyWebLog.App": "0.9.2",
|
||||
"MyWebLog.Data.RethinkDB": "0.9.2",
|
||||
"MyWebLog.Entities": "0.9.2",
|
||||
"NodaTime": "2.0.0-alpha20160729"
|
||||
},
|
||||
"tools": {
|
||||
"dotnet-compile-fsc":"1.0.0-preview2-*"
|
||||
},
|
||||
"frameworks": {
|
||||
"netcoreapp1.0": {
|
||||
"dependencies": {
|
||||
"Microsoft.NETCore.App": {
|
||||
"type": "platform",
|
||||
"version": "1.0.1"
|
||||
},
|
||||
"Microsoft.FSharp.Core.netcore": "1.0.0-alpha-160629"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
namespace MyWebLog.Web
|
||||
|
||||
type Web() =
|
||||
member this.X = "F#"
|
|
@ -1,70 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="12.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
|
||||
<PropertyGroup>
|
||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
||||
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
||||
<SchemaVersion>2.0</SchemaVersion>
|
||||
<ProjectGuid>07e60874-6cf5-4d53-aee0-f17ef28228dd</ProjectGuid>
|
||||
<OutputType>Library</OutputType>
|
||||
<RootNamespace>MyWebLog.Tests</RootNamespace>
|
||||
<AssemblyName>MyWebLog.Tests</AssemblyName>
|
||||
<TargetFrameworkVersion>v4.5.2</TargetFrameworkVersion>
|
||||
<TargetFSharpCoreVersion>4.4.0.0</TargetFSharpCoreVersion>
|
||||
<Name>MyWebLog.Tests</Name>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<DebugType>full</DebugType>
|
||||
<Optimize>false</Optimize>
|
||||
<Tailcalls>false</Tailcalls>
|
||||
<OutputPath>bin\Debug\</OutputPath>
|
||||
<DefineConstants>DEBUG;TRACE</DefineConstants>
|
||||
<WarningLevel>3</WarningLevel>
|
||||
<DocumentationFile>bin\Debug\MyWebLog.Tests.xml</DocumentationFile>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||
<DebugType>pdbonly</DebugType>
|
||||
<Optimize>true</Optimize>
|
||||
<Tailcalls>true</Tailcalls>
|
||||
<OutputPath>bin\Release\</OutputPath>
|
||||
<DefineConstants>TRACE</DefineConstants>
|
||||
<WarningLevel>3</WarningLevel>
|
||||
<DocumentationFile>bin\Release\MyWebLog.Tests.xml</DocumentationFile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="mscorlib" />
|
||||
<Reference Include="FSharp.Core, Version=$(TargetFSharpCoreVersion), Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
||||
<Private>True</Private>
|
||||
</Reference>
|
||||
<Reference Include="System" />
|
||||
<Reference Include="System.Core" />
|
||||
<Reference Include="System.Numerics" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="MyWebLog.Tests.fs" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup>
|
||||
<MinimumVisualStudioVersion Condition="'$(MinimumVisualStudioVersion)' == ''">11</MinimumVisualStudioVersion>
|
||||
</PropertyGroup>
|
||||
<Choose>
|
||||
<When Condition="'$(VisualStudioVersion)' == '11.0'">
|
||||
<PropertyGroup Condition="Exists('$(MSBuildExtensionsPath32)\..\Microsoft SDKs\F#\3.0\Framework\v4.0\Microsoft.FSharp.Targets')">
|
||||
<FSharpTargetsPath>$(MSBuildExtensionsPath32)\..\Microsoft SDKs\F#\3.0\Framework\v4.0\Microsoft.FSharp.Targets</FSharpTargetsPath>
|
||||
</PropertyGroup>
|
||||
</When>
|
||||
<Otherwise>
|
||||
<PropertyGroup Condition="Exists('$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\FSharp\Microsoft.FSharp.Targets')">
|
||||
<FSharpTargetsPath>$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\FSharp\Microsoft.FSharp.Targets</FSharpTargetsPath>
|
||||
</PropertyGroup>
|
||||
</Otherwise>
|
||||
</Choose>
|
||||
<Import Project="$(FSharpTargetsPath)" Condition="Exists('$(FSharpTargetsPath)')" />
|
||||
<!-- To modify your build process, add your task inside one of the targets below and uncomment it.
|
||||
Other similar extension points exist, see Microsoft.Common.targets.
|
||||
<Target Name="BeforeBuild">
|
||||
</Target>
|
||||
<Target Name="AfterBuild">
|
||||
</Target>
|
||||
-->
|
||||
</Project>
|
37
src/MyWebLog.sln
Normal file
37
src/MyWebLog.sln
Normal file
|
@ -0,0 +1,37 @@
|
|||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.1.32210.238
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MyWebLog.Domain", "MyWebLog.Domain\MyWebLog.Domain.fsproj", "{8CA99122-888A-4524-8C1B-685F0A4B7B4B}"
|
||||
EndProject
|
||||
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MyWebLog.Data", "MyWebLog.Data\MyWebLog.Data.fsproj", "{D284584D-2CB2-40C8-B605-6D0FD84D9D3D}"
|
||||
EndProject
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "MyWebLog", "MyWebLog\MyWebLog.fsproj", "{5655B63D-429F-4CCD-A14C-FBD74D987ECB}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{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
|
||||
{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
|
||||
{5655B63D-429F-4CCD-A14C-FBD74D987ECB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5655B63D-429F-4CCD-A14C-FBD74D987ECB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5655B63D-429F-4CCD-A14C-FBD74D987ECB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5655B63D-429F-4CCD-A14C-FBD74D987ECB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {70EEF0F4-8709-44A8-AD9B-24D1EB84CFB4}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
157
src/MyWebLog/Caches.fs
Normal file
157
src/MyWebLog/Caches.fs
Normal file
|
@ -0,0 +1,157 @@
|
|||
namespace MyWebLog
|
||||
|
||||
open Microsoft.AspNetCore.Http
|
||||
open MyWebLog.Data
|
||||
|
||||
/// Extension properties on HTTP context for web log
|
||||
[<AutoOpen>]
|
||||
module Extensions =
|
||||
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
|
||||
type HttpContext with
|
||||
/// The web log for the current request
|
||||
member this.WebLog = this.Items["webLog"] :?> WebLog
|
||||
|
||||
/// The data implementation
|
||||
member this.Data = this.RequestServices.GetRequiredService<IData> ()
|
||||
|
||||
|
||||
open System.Collections.Concurrent
|
||||
|
||||
/// <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 WebLogCache =
|
||||
|
||||
/// The cache of web log details
|
||||
let mutable private _cache : WebLog list = []
|
||||
|
||||
/// Try to get the web log for the current request (longest matching URL base wins)
|
||||
let tryGet (path : string) =
|
||||
_cache
|
||||
|> List.filter (fun wl -> path.StartsWith wl.urlBase)
|
||||
|> List.sortByDescending (fun wl -> wl.urlBase.Length)
|
||||
|> List.tryHead
|
||||
|
||||
/// Cache the web log for a particular host
|
||||
let set webLog =
|
||||
_cache <- webLog :: (_cache |> List.filter (fun wl -> wl.id <> webLog.id))
|
||||
|
||||
/// Fill the web log cache from the database
|
||||
let fill (data : IData) = backgroundTask {
|
||||
let! webLogs = data.WebLog.all ()
|
||||
_cache <- webLogs
|
||||
}
|
||||
|
||||
|
||||
/// A cache of page information needed to display the page list in templates
|
||||
module PageListCache =
|
||||
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
/// Cache of displayed pages
|
||||
let private _cache = ConcurrentDictionary<string, DisplayPage[]> ()
|
||||
|
||||
/// Are there pages cached for this web log?
|
||||
let exists (ctx : HttpContext) = _cache.ContainsKey ctx.WebLog.urlBase
|
||||
|
||||
/// Get the pages for the web log for this request
|
||||
let get (ctx : HttpContext) = _cache[ctx.WebLog.urlBase]
|
||||
|
||||
/// Update the pages for the current web log
|
||||
let update (ctx : HttpContext) = backgroundTask {
|
||||
let webLog = ctx.WebLog
|
||||
let! pages = ctx.Data.Page.findListed webLog.id
|
||||
_cache[webLog.urlBase] <-
|
||||
pages
|
||||
|> List.map (fun pg -> DisplayPage.fromPage webLog { pg with text = "" })
|
||||
|> Array.ofList
|
||||
}
|
||||
|
||||
|
||||
/// Cache of all categories, indexed by web log
|
||||
module CategoryCache =
|
||||
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
/// The cache itself
|
||||
let private _cache = ConcurrentDictionary<string, DisplayCategory[]> ()
|
||||
|
||||
/// Are there categories cached for this web log?
|
||||
let exists (ctx : HttpContext) = _cache.ContainsKey ctx.WebLog.urlBase
|
||||
|
||||
/// Get the categories for the web log for this request
|
||||
let get (ctx : HttpContext) = _cache[ctx.WebLog.urlBase]
|
||||
|
||||
/// Update the cache with fresh data
|
||||
let update (ctx : HttpContext) = backgroundTask {
|
||||
let! cats = ctx.Data.Category.findAllForView ctx.WebLog.id
|
||||
_cache[ctx.WebLog.urlBase] <- cats
|
||||
}
|
||||
|
||||
|
||||
/// Cache for parsed templates
|
||||
module TemplateCache =
|
||||
|
||||
open System
|
||||
open System.Text.RegularExpressions
|
||||
open DotLiquid
|
||||
|
||||
/// Cache of parsed templates
|
||||
let private _cache = ConcurrentDictionary<string, Template> ()
|
||||
|
||||
/// Custom include parameter pattern
|
||||
let private hasInclude = Regex ("""{% include_template \"(.*)\" %}""", RegexOptions.None, TimeSpan.FromSeconds 2)
|
||||
|
||||
/// Get a template for the given theme and template name
|
||||
let get (themeId : string) (templateName : string) (data : IData) = backgroundTask {
|
||||
let templatePath = $"{themeId}/{templateName}"
|
||||
match _cache.ContainsKey templatePath with
|
||||
| true -> ()
|
||||
| false ->
|
||||
match! data.Theme.findById (ThemeId themeId) with
|
||||
| Some theme ->
|
||||
let mutable text = (theme.templates |> List.find (fun t -> t.name = templateName)).text
|
||||
while hasInclude.IsMatch text do
|
||||
let child = hasInclude.Match text
|
||||
let childText = (theme.templates |> List.find (fun t -> t.name = child.Groups[1].Value)).text
|
||||
text <- text.Replace (child.Value, childText)
|
||||
_cache[templatePath] <- Template.Parse (text, SyntaxCompatibility.DotLiquid22)
|
||||
| None -> ()
|
||||
return _cache[templatePath]
|
||||
}
|
||||
|
||||
/// Invalidate all template cache entries for the given theme ID
|
||||
let invalidateTheme (themeId : string) =
|
||||
_cache.Keys
|
||||
|> Seq.filter (fun key -> key.StartsWith themeId)
|
||||
|> List.ofSeq
|
||||
|> List.iter (fun key -> match _cache.TryRemove key with _, _ -> ())
|
||||
|
||||
|
||||
/// A cache of asset names by themes
|
||||
module ThemeAssetCache =
|
||||
|
||||
/// A list of asset names for each theme
|
||||
let private _cache = ConcurrentDictionary<ThemeId, string list> ()
|
||||
|
||||
/// Retrieve the assets for the given theme ID
|
||||
let get themeId = _cache[themeId]
|
||||
|
||||
/// Refresh the list of assets for the given theme
|
||||
let refreshTheme themeId (data : IData) = backgroundTask {
|
||||
let! assets = data.ThemeAsset.findByTheme themeId
|
||||
_cache[themeId] <- assets |> List.map (fun a -> match a.id with ThemeAssetId (_, path) -> path)
|
||||
}
|
||||
|
||||
/// Fill the theme asset cache
|
||||
let fill (data : IData) = backgroundTask {
|
||||
let! assets = data.ThemeAsset.all ()
|
||||
for asset in assets do
|
||||
let (ThemeAssetId (themeId, path)) = asset.id
|
||||
if not (_cache.ContainsKey themeId) then _cache[themeId] <- []
|
||||
_cache[themeId] <- path :: _cache[themeId]
|
||||
}
|
237
src/MyWebLog/DotLiquidBespoke.fs
Normal file
237
src/MyWebLog/DotLiquidBespoke.fs
Normal file
|
@ -0,0 +1,237 @@
|
|||
/// Custom DotLiquid filters and tags
|
||||
module MyWebLog.DotLiquidBespoke
|
||||
|
||||
open System
|
||||
open System.IO
|
||||
open System.Web
|
||||
open DotLiquid
|
||||
open Giraffe.ViewEngine
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
/// Get the current web log from the DotLiquid context
|
||||
let webLog (ctx : Context) =
|
||||
ctx.Environments[0].["web_log"] :?> WebLog
|
||||
|
||||
/// Does an asset exist for the current theme?
|
||||
let assetExists fileName (webLog : WebLog) =
|
||||
ThemeAssetCache.get (ThemeId webLog.themePath) |> List.exists (fun it -> it = fileName)
|
||||
|
||||
/// Obtain the link from known types
|
||||
let permalink (ctx : Context) (item : obj) (linkFunc : WebLog -> Permalink -> string) =
|
||||
match item with
|
||||
| :? String as link -> Some link
|
||||
| :? DisplayPage as page -> Some page.permalink
|
||||
| :? PostListItem as post -> Some post.permalink
|
||||
| :? DropProxy as proxy -> Option.ofObj proxy["permalink"] |> Option.map string
|
||||
| _ -> None
|
||||
|> function
|
||||
| Some link -> linkFunc (webLog ctx) (Permalink link)
|
||||
| None -> $"alert('unknown item type {item.GetType().Name}')"
|
||||
|
||||
|
||||
/// A filter to generate an absolute link
|
||||
type AbsoluteLinkFilter () =
|
||||
static member AbsoluteLink (ctx : Context, item : obj) =
|
||||
permalink ctx item WebLog.absoluteUrl
|
||||
|
||||
|
||||
/// A filter to generate a link with posts categorized under the given category
|
||||
type CategoryLinkFilter () =
|
||||
static member CategoryLink (ctx : Context, catObj : obj) =
|
||||
match catObj with
|
||||
| :? DisplayCategory as cat -> Some cat.slug
|
||||
| :? DropProxy as proxy -> Option.ofObj proxy["slug"] |> Option.map string
|
||||
| _ -> None
|
||||
|> function
|
||||
| Some slug -> WebLog.relativeUrl (webLog ctx) (Permalink $"category/{slug}/")
|
||||
| None -> $"alert('unknown category object type {catObj.GetType().Name}')"
|
||||
|
||||
|
||||
/// A filter to generate a link that will edit a page
|
||||
type EditPageLinkFilter () =
|
||||
static member EditPageLink (ctx : Context, pageObj : obj) =
|
||||
match pageObj with
|
||||
| :? DisplayPage as page -> Some page.id
|
||||
| :? DropProxy as proxy -> Option.ofObj proxy["id"] |> Option.map string
|
||||
| :? String as theId -> Some theId
|
||||
| _ -> None
|
||||
|> function
|
||||
| Some pageId -> WebLog.relativeUrl (webLog ctx) (Permalink $"admin/page/{pageId}/edit")
|
||||
| None -> $"alert('unknown page object type {pageObj.GetType().Name}')"
|
||||
|
||||
|
||||
/// A filter to generate a link that will edit a post
|
||||
type EditPostLinkFilter () =
|
||||
static member EditPostLink (ctx : Context, postObj : obj) =
|
||||
match postObj with
|
||||
| :? PostListItem as post -> Some post.id
|
||||
| :? DropProxy as proxy -> Option.ofObj proxy["id"] |> Option.map string
|
||||
| :? String as theId -> Some theId
|
||||
| _ -> None
|
||||
|> function
|
||||
| Some postId -> WebLog.relativeUrl (webLog ctx) (Permalink $"admin/post/{postId}/edit")
|
||||
| None -> $"alert('unknown post object type {postObj.GetType().Name}')"
|
||||
|
||||
|
||||
/// A filter to generate nav links, highlighting the active link (exact match)
|
||||
type NavLinkFilter () =
|
||||
static member NavLink (ctx : Context, url : string, text : string) =
|
||||
let webLog = webLog ctx
|
||||
let _, path = WebLog.hostAndPath webLog
|
||||
let path = if path = "" then path else $"{path.Substring 1}/"
|
||||
seq {
|
||||
"<li class=\"nav-item\"><a class=\"nav-link"
|
||||
if (string ctx.Environments[0].["current_page"]).StartsWith $"{path}{url}" then " active"
|
||||
"\" href=\""
|
||||
WebLog.relativeUrl webLog (Permalink url)
|
||||
"\">"
|
||||
text
|
||||
"</a></li>"
|
||||
}
|
||||
|> Seq.fold (+) ""
|
||||
|
||||
|
||||
/// A filter to generate a link for theme asset (image, stylesheet, script, etc.)
|
||||
type ThemeAssetFilter () =
|
||||
static member ThemeAsset (ctx : Context, asset : string) =
|
||||
let webLog = webLog ctx
|
||||
WebLog.relativeUrl webLog (Permalink $"themes/{webLog.themePath}/{asset}")
|
||||
|
||||
|
||||
/// Create various items in the page header based on the state of the page being generated
|
||||
type PageHeadTag () =
|
||||
inherit Tag ()
|
||||
|
||||
override this.Render (context : Context, result : TextWriter) =
|
||||
let webLog = webLog context
|
||||
// spacer
|
||||
let s = " "
|
||||
let getBool name =
|
||||
context.Environments[0].[name] |> Option.ofObj |> Option.map Convert.ToBoolean |> Option.defaultValue false
|
||||
|
||||
result.WriteLine $"""<meta name="generator" content="{context.Environments[0].["generator"]}">"""
|
||||
|
||||
// Theme assets
|
||||
if assetExists "style.css" webLog then
|
||||
result.WriteLine $"""{s}<link rel="stylesheet" href="{ThemeAssetFilter.ThemeAsset (context, "style.css")}">"""
|
||||
if assetExists "favicon.ico" webLog then
|
||||
result.WriteLine $"""{s}<link rel="icon" href="{ThemeAssetFilter.ThemeAsset (context, "favicon.ico")}">"""
|
||||
|
||||
// RSS feeds and canonical URLs
|
||||
let feedLink title url =
|
||||
let escTitle = HttpUtility.HtmlAttributeEncode title
|
||||
let relUrl = WebLog.relativeUrl webLog (Permalink url)
|
||||
$"""{s}<link rel="alternate" type="application/rss+xml" title="{escTitle}" href="{relUrl}">"""
|
||||
|
||||
if webLog.rss.feedEnabled && getBool "is_home" then
|
||||
result.WriteLine (feedLink webLog.name webLog.rss.feedName)
|
||||
result.WriteLine $"""{s}<link rel="canonical" href="{WebLog.absoluteUrl webLog Permalink.empty}">"""
|
||||
|
||||
if webLog.rss.categoryEnabled && getBool "is_category_home" then
|
||||
let slug = context.Environments[0].["slug"] :?> string
|
||||
result.WriteLine (feedLink webLog.name $"category/{slug}/{webLog.rss.feedName}")
|
||||
|
||||
if webLog.rss.tagEnabled && getBool "is_tag_home" then
|
||||
let slug = context.Environments[0].["slug"] :?> string
|
||||
result.WriteLine (feedLink webLog.name $"tag/{slug}/{webLog.rss.feedName}")
|
||||
|
||||
if getBool "is_post" then
|
||||
let post = context.Environments[0].["model"] :?> PostDisplay
|
||||
let url = WebLog.absoluteUrl webLog (Permalink post.posts[0].permalink)
|
||||
result.WriteLine $"""{s}<link rel="canonical" href="{url}">"""
|
||||
|
||||
if getBool "is_page" then
|
||||
let page = context.Environments[0].["page"] :?> DisplayPage
|
||||
let url = WebLog.absoluteUrl webLog (Permalink page.permalink)
|
||||
result.WriteLine $"""{s}<link rel="canonical" href="{url}">"""
|
||||
|
||||
|
||||
/// Create various items in the page header based on the state of the page being generated
|
||||
type PageFootTag () =
|
||||
inherit Tag ()
|
||||
|
||||
override this.Render (context : Context, result : TextWriter) =
|
||||
let webLog = webLog context
|
||||
// spacer
|
||||
let s = " "
|
||||
|
||||
if webLog.autoHtmx then
|
||||
result.WriteLine $"{s}{RenderView.AsString.htmlNode Htmx.Script.minified}"
|
||||
|
||||
if assetExists "script.js" webLog then
|
||||
result.WriteLine $"""{s}<script src="{ThemeAssetFilter.ThemeAsset (context, "script.js")}"></script>"""
|
||||
|
||||
|
||||
/// A filter to generate a relative link
|
||||
type RelativeLinkFilter () =
|
||||
static member RelativeLink (ctx : Context, item : obj) =
|
||||
permalink ctx item WebLog.relativeUrl
|
||||
|
||||
|
||||
/// A filter to generate a link with posts tagged with the given tag
|
||||
type TagLinkFilter () =
|
||||
static member TagLink (ctx : Context, tag : string) =
|
||||
ctx.Environments[0].["tag_mappings"] :?> TagMap list
|
||||
|> List.tryFind (fun it -> it.tag = tag)
|
||||
|> function
|
||||
| Some tagMap -> tagMap.urlValue
|
||||
| None -> tag.Replace (" ", "+")
|
||||
|> function tagUrl -> WebLog.relativeUrl (webLog ctx) (Permalink $"tag/{tagUrl}/")
|
||||
|
||||
|
||||
/// Create links for a user to log on or off, and a dashboard link if they are logged off
|
||||
type UserLinksTag () =
|
||||
inherit Tag ()
|
||||
|
||||
override this.Render (context : Context, result : TextWriter) =
|
||||
let webLog = webLog context
|
||||
let link it = WebLog.relativeUrl webLog (Permalink it)
|
||||
seq {
|
||||
"""<ul class="navbar-nav flex-grow-1 justify-content-end">"""
|
||||
match Convert.ToBoolean context.Environments[0].["logged_on"] with
|
||||
| true ->
|
||||
$"""<li class="nav-item"><a class="nav-link" href="{link "admin/dashboard"}">Dashboard</a></li>"""
|
||||
$"""<li class="nav-item"><a class="nav-link" href="{link "user/log-off"}">Log Off</a></li>"""
|
||||
| false ->
|
||||
$"""<li class="nav-item"><a class="nav-link" href="{link "user/log-on"}">Log On</a></li>"""
|
||||
"</ul>"
|
||||
}
|
||||
|> Seq.iter result.WriteLine
|
||||
|
||||
/// A filter to retrieve the value of a meta item from a list
|
||||
// (shorter than `{% assign item = list | where: "name", [name] | first %}{{ item.value }}`)
|
||||
type ValueFilter () =
|
||||
static member Value (_ : Context, items : MetaItem list, name : string) =
|
||||
match items |> List.tryFind (fun it -> it.name = name) with
|
||||
| Some item -> item.value
|
||||
| None -> $"-- {name} not found --"
|
||||
|
||||
|
||||
open System.Collections.Generic
|
||||
open Microsoft.AspNetCore.Antiforgery
|
||||
|
||||
/// Register custom filters/tags and safe types
|
||||
let register () =
|
||||
[ typeof<AbsoluteLinkFilter>; typeof<CategoryLinkFilter>; typeof<EditPageLinkFilter>; typeof<EditPostLinkFilter>
|
||||
typeof<NavLinkFilter>; typeof<RelativeLinkFilter>; typeof<TagLinkFilter>; typeof<ThemeAssetFilter>
|
||||
typeof<ValueFilter>
|
||||
]
|
||||
|> List.iter Template.RegisterFilter
|
||||
|
||||
Template.RegisterTag<PageHeadTag> "page_head"
|
||||
Template.RegisterTag<PageFootTag> "page_foot"
|
||||
Template.RegisterTag<UserLinksTag> "user_links"
|
||||
|
||||
[ // Domain types
|
||||
typeof<CustomFeed>; typeof<MetaItem>; typeof<Page>; typeof<RssOptions>; typeof<TagMap>; typeof<WebLog>
|
||||
// View models
|
||||
typeof<DashboardModel>; typeof<DisplayCategory>; typeof<DisplayCustomFeed>; typeof<DisplayPage>
|
||||
typeof<EditCategoryModel>; typeof<EditCustomFeedModel>; typeof<EditPageModel>; typeof<EditPostModel>
|
||||
typeof<EditRssModel>; typeof<EditTagMapModel>; typeof<EditUserModel>; typeof<LogOnModel>
|
||||
typeof<ManagePermalinksModel>; typeof<PostDisplay>; typeof<PostListItem>; typeof<SettingsModel>
|
||||
typeof<UserMessage>
|
||||
// Framework types
|
||||
typeof<AntiforgeryTokenSet>; typeof<int option>; typeof<KeyValuePair>; typeof<MetaItem list>
|
||||
typeof<string list>; typeof<string option>; typeof<TagMap list>
|
||||
]
|
||||
|> List.iter (fun it -> Template.RegisterSafeType (it, [| "*" |]))
|
504
src/MyWebLog/Handlers/Admin.fs
Normal file
504
src/MyWebLog/Handlers/Admin.fs
Normal file
|
@ -0,0 +1,504 @@
|
|||
/// Handlers to manipulate admin functions
|
||||
module MyWebLog.Handlers.Admin
|
||||
|
||||
open System.Threading.Tasks
|
||||
open DotLiquid
|
||||
open Giraffe
|
||||
open MyWebLog
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
// GET /admin
|
||||
let dashboard : HttpHandler = fun next ctx -> task {
|
||||
let webLogId = ctx.WebLog.id
|
||||
let data = ctx.Data
|
||||
let getCount (f : WebLogId -> Task<int>) = f webLogId
|
||||
let! posts = data.Post.countByStatus Published |> getCount
|
||||
let! drafts = data.Post.countByStatus Draft |> 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!
|
||||
Hash.FromAnonymousObject {|
|
||||
page_title = "Dashboard"
|
||||
model =
|
||||
{ posts = posts
|
||||
drafts = drafts
|
||||
pages = pages
|
||||
listedPages = listed
|
||||
categories = cats
|
||||
topLevelCategories = topCats
|
||||
}
|
||||
|}
|
||||
|> viewForTheme "admin" "dashboard" next ctx
|
||||
}
|
||||
|
||||
// -- CATEGORIES --
|
||||
|
||||
// GET /admin/categories
|
||||
let listCategories : HttpHandler = fun next ctx -> task {
|
||||
let! catListTemplate = TemplateCache.get "admin" "category-list-body" ctx.Data
|
||||
let hash = Hash.FromAnonymousObject {|
|
||||
web_log = ctx.WebLog
|
||||
categories = CategoryCache.get ctx
|
||||
page_title = "Categories"
|
||||
csrf = csrfToken ctx
|
||||
|}
|
||||
hash.Add ("category_list", catListTemplate.Render hash)
|
||||
return! viewForTheme "admin" "category-list" next ctx hash
|
||||
}
|
||||
|
||||
// GET /admin/categories/bare
|
||||
let listCategoriesBare : HttpHandler = fun next ctx -> task {
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
categories = CategoryCache.get ctx
|
||||
csrf = csrfToken ctx
|
||||
|}
|
||||
|> bareForTheme "admin" "category-list-body" next ctx
|
||||
}
|
||||
|
||||
|
||||
// GET /admin/category/{id}/edit
|
||||
let editCategory catId : HttpHandler = fun next ctx -> task {
|
||||
let! result = task {
|
||||
match catId with
|
||||
| "new" -> return Some ("Add a New Category", { Category.empty with id = CategoryId "new" })
|
||||
| _ ->
|
||||
match! ctx.Data.Category.findById (CategoryId catId) ctx.WebLog.id with
|
||||
| Some cat -> return Some ("Edit Category", cat)
|
||||
| None -> return None
|
||||
}
|
||||
match result with
|
||||
| Some (title, cat) ->
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
csrf = csrfToken ctx
|
||||
model = EditCategoryModel.fromCategory cat
|
||||
page_title = title
|
||||
categories = CategoryCache.get ctx
|
||||
|}
|
||||
|> bareForTheme "admin" "category-edit" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/category/save
|
||||
let saveCategory : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
let data = ctx.Data
|
||||
let! model = ctx.BindFormAsync<EditCategoryModel> ()
|
||||
let! category = task {
|
||||
match model.categoryId with
|
||||
| "new" -> return Some { Category.empty with id = CategoryId.create (); webLogId = webLog.id }
|
||||
| catId -> return! data.Category.findById (CategoryId catId) webLog.id
|
||||
}
|
||||
match category with
|
||||
| Some cat ->
|
||||
let cat =
|
||||
{ cat with
|
||||
name = model.name
|
||||
slug = model.slug
|
||||
description = if model.description = "" then None else Some model.description
|
||||
parentId = if model.parentId = "" then None else Some (CategoryId model.parentId)
|
||||
}
|
||||
do! (match model.categoryId with "new" -> data.Category.add | _ -> data.Category.update) cat
|
||||
do! CategoryCache.update ctx
|
||||
do! addMessage ctx { UserMessage.success with message = "Category saved successfully" }
|
||||
return! listCategoriesBare next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/category/{id}/delete
|
||||
let deleteCategory catId : HttpHandler = fun next ctx -> task {
|
||||
match! ctx.Data.Category.delete (CategoryId catId) ctx.WebLog.id with
|
||||
| true ->
|
||||
do! CategoryCache.update ctx
|
||||
do! addMessage ctx { UserMessage.success with message = "Category deleted successfully" }
|
||||
| false -> do! addMessage ctx { UserMessage.error with message = "Category not found; cannot delete" }
|
||||
return! listCategoriesBare next ctx
|
||||
}
|
||||
|
||||
// -- PAGES --
|
||||
|
||||
// GET /admin/pages
|
||||
// GET /admin/pages/page/{pageNbr}
|
||||
let listPages pageNbr : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
let! pages = ctx.Data.Page.findPageOfPages webLog.id pageNbr
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
csrf = csrfToken ctx
|
||||
pages = pages |> List.map (DisplayPage.fromPageMinimal webLog)
|
||||
page_title = "Pages"
|
||||
page_nbr = pageNbr
|
||||
prev_page = if pageNbr = 2 then "" else $"/page/{pageNbr - 1}"
|
||||
next_page = $"/page/{pageNbr + 1}"
|
||||
|}
|
||||
|> viewForTheme "admin" "page-list" next ctx
|
||||
}
|
||||
|
||||
// GET /admin/page/{id}/edit
|
||||
let editPage pgId : HttpHandler = fun next ctx -> task {
|
||||
let! result = task {
|
||||
match pgId with
|
||||
| "new" -> return Some ("Add a New Page", { Page.empty with id = PageId "new" })
|
||||
| _ ->
|
||||
match! ctx.Data.Page.findFullById (PageId pgId) ctx.WebLog.id with
|
||||
| Some page -> return Some ("Edit Page", page)
|
||||
| None -> return None
|
||||
}
|
||||
match result with
|
||||
| Some (title, page) ->
|
||||
let model = EditPageModel.fromPage page
|
||||
let! templates = templatesForTheme ctx "page"
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
csrf = csrfToken ctx
|
||||
model = model
|
||||
metadata = Array.zip model.metaNames model.metaValues
|
||||
|> Array.mapi (fun idx (name, value) -> [| string idx; name; value |])
|
||||
page_title = title
|
||||
templates = templates
|
||||
|}
|
||||
|> viewForTheme "admin" "page-edit" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// GET /admin/page/{id}/permalinks
|
||||
let editPagePermalinks pgId : HttpHandler = fun next ctx -> task {
|
||||
match! ctx.Data.Page.findFullById (PageId pgId) ctx.WebLog.id with
|
||||
| Some pg ->
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
csrf = csrfToken ctx
|
||||
model = ManagePermalinksModel.fromPage pg
|
||||
page_title = $"Manage Prior Permalinks"
|
||||
|}
|
||||
|> viewForTheme "admin" "permalinks" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/page/permalinks
|
||||
let savePagePermalinks : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
let! model = ctx.BindFormAsync<ManagePermalinksModel> ()
|
||||
let links = model.prior |> Array.map Permalink |> List.ofArray
|
||||
match! ctx.Data.Page.updatePriorPermalinks (PageId model.id) webLog.id links with
|
||||
| true ->
|
||||
do! addMessage ctx { UserMessage.success with message = "Page permalinks saved successfully" }
|
||||
return! redirectToGet (WebLog.relativeUrl webLog (Permalink $"admin/page/{model.id}/permalinks")) next ctx
|
||||
| false -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/page/{id}/delete
|
||||
let deletePage pgId : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
match! ctx.Data.Page.delete (PageId pgId) webLog.id with
|
||||
| true ->
|
||||
do! PageListCache.update ctx
|
||||
do! addMessage ctx { UserMessage.success with message = "Page deleted successfully" }
|
||||
| false -> do! addMessage ctx { UserMessage.error with message = "Page not found; nothing deleted" }
|
||||
return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/pages")) next ctx
|
||||
}
|
||||
|
||||
open System
|
||||
|
||||
#nowarn "3511"
|
||||
|
||||
// POST /admin/page/save
|
||||
let savePage : HttpHandler = fun next ctx -> task {
|
||||
let! model = ctx.BindFormAsync<EditPageModel> ()
|
||||
let webLog = ctx.WebLog
|
||||
let data = ctx.Data
|
||||
let now = DateTime.UtcNow
|
||||
let! pg = task {
|
||||
match model.pageId with
|
||||
| "new" ->
|
||||
return Some
|
||||
{ Page.empty with
|
||||
id = PageId.create ()
|
||||
webLogId = webLog.id
|
||||
authorId = userId ctx
|
||||
publishedOn = now
|
||||
}
|
||||
| pgId -> return! data.Page.findFullById (PageId pgId) webLog.id
|
||||
}
|
||||
match pg with
|
||||
| Some page ->
|
||||
let updateList = page.showInPageList <> model.isShownInPageList
|
||||
let revision = { asOf = now; text = MarkupText.parse $"{model.source}: {model.text}" }
|
||||
// Detect a permalink change, and add the prior one to the prior list
|
||||
let page =
|
||||
match Permalink.toString page.permalink with
|
||||
| "" -> page
|
||||
| link when link = model.permalink -> page
|
||||
| _ -> { page with priorPermalinks = page.permalink :: page.priorPermalinks }
|
||||
let page =
|
||||
{ page with
|
||||
title = model.title
|
||||
permalink = Permalink model.permalink
|
||||
updatedOn = now
|
||||
showInPageList = model.isShownInPageList
|
||||
template = match model.template with "" -> None | tmpl -> Some tmpl
|
||||
text = MarkupText.toHtml revision.text
|
||||
metadata = Seq.zip model.metaNames model.metaValues
|
||||
|> Seq.filter (fun it -> fst it > "")
|
||||
|> Seq.map (fun it -> { name = fst it; value = snd it })
|
||||
|> Seq.sortBy (fun it -> $"{it.name.ToLower ()} {it.value.ToLower ()}")
|
||||
|> List.ofSeq
|
||||
revisions = match page.revisions |> List.tryHead with
|
||||
| Some r when r.text = revision.text -> page.revisions
|
||||
| _ -> revision :: page.revisions
|
||||
}
|
||||
do! (if model.pageId = "new" then data.Page.add else data.Page.update) page
|
||||
if updateList then do! PageListCache.update ctx
|
||||
do! addMessage ctx { UserMessage.success with message = "Page saved successfully" }
|
||||
return!
|
||||
redirectToGet (WebLog.relativeUrl webLog (Permalink $"admin/page/{PageId.toString page.id}/edit")) next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// -- TAG MAPPINGS --
|
||||
|
||||
open Microsoft.AspNetCore.Http
|
||||
|
||||
/// Get the hash necessary to render the tag mapping list
|
||||
let private tagMappingHash (ctx : HttpContext) = task {
|
||||
let! mappings = ctx.Data.TagMap.findByWebLog ctx.WebLog.id
|
||||
return Hash.FromAnonymousObject {|
|
||||
web_log = ctx.WebLog
|
||||
csrf = csrfToken ctx
|
||||
mappings = mappings
|
||||
mapping_ids = mappings |> List.map (fun it -> { name = it.tag; value = TagMapId.toString it.id })
|
||||
|}
|
||||
}
|
||||
|
||||
// GET /admin/settings/tag-mappings
|
||||
let tagMappings : HttpHandler = fun next ctx -> task {
|
||||
let! hash = tagMappingHash ctx
|
||||
let! listTemplate = TemplateCache.get "admin" "tag-mapping-list-body" ctx.Data
|
||||
|
||||
hash.Add ("tag_mapping_list", listTemplate.Render hash)
|
||||
hash.Add ("page_title", "Tag Mappings")
|
||||
|
||||
return! viewForTheme "admin" "tag-mapping-list" next ctx hash
|
||||
}
|
||||
|
||||
// GET /admin/settings/tag-mappings/bare
|
||||
let tagMappingsBare : HttpHandler = fun next ctx -> task {
|
||||
let! hash = tagMappingHash ctx
|
||||
return! bareForTheme "admin" "tag-mapping-list-body" next ctx hash
|
||||
}
|
||||
|
||||
// GET /admin/settings/tag-mapping/{id}/edit
|
||||
let editMapping tagMapId : HttpHandler = fun next ctx -> task {
|
||||
let isNew = tagMapId = "new"
|
||||
let tagMap =
|
||||
if isNew then
|
||||
Task.FromResult (Some { TagMap.empty with id = TagMapId "new" })
|
||||
else
|
||||
ctx.Data.TagMap.findById (TagMapId tagMapId) ctx.WebLog.id
|
||||
match! tagMap with
|
||||
| Some tm ->
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
csrf = csrfToken ctx
|
||||
model = EditTagMapModel.fromMapping tm
|
||||
page_title = if isNew then "Add Tag Mapping" else $"Mapping for {tm.tag} Tag"
|
||||
|}
|
||||
|> bareForTheme "admin" "tag-mapping-edit" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/settings/tag-mapping/save
|
||||
let saveMapping : HttpHandler = fun next ctx -> task {
|
||||
let data = ctx.Data
|
||||
let! model = ctx.BindFormAsync<EditTagMapModel> ()
|
||||
let tagMap =
|
||||
if model.id = "new" then
|
||||
Task.FromResult (Some { TagMap.empty with id = TagMapId.create (); webLogId = ctx.WebLog.id })
|
||||
else
|
||||
data.TagMap.findById (TagMapId model.id) ctx.WebLog.id
|
||||
match! tagMap with
|
||||
| Some tm ->
|
||||
do! data.TagMap.save { tm with tag = model.tag.ToLower (); urlValue = model.urlValue.ToLower () }
|
||||
do! addMessage ctx { UserMessage.success with message = "Tag mapping saved successfully" }
|
||||
return! tagMappingsBare next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/settings/tag-mapping/{id}/delete
|
||||
let deleteMapping tagMapId : HttpHandler = fun next ctx -> task {
|
||||
match! ctx.Data.TagMap.delete (TagMapId tagMapId) ctx.WebLog.id with
|
||||
| true -> do! addMessage ctx { UserMessage.success with message = "Tag mapping deleted successfully" }
|
||||
| false -> do! addMessage ctx { UserMessage.error with message = "Tag mapping not found; nothing deleted" }
|
||||
return! tagMappingsBare next ctx
|
||||
}
|
||||
|
||||
// -- THEMES --
|
||||
|
||||
open System.IO
|
||||
open System.IO.Compression
|
||||
open System.Text.RegularExpressions
|
||||
open MyWebLog.Data
|
||||
|
||||
// GET /admin/theme/update
|
||||
let themeUpdatePage : HttpHandler = fun next ctx -> task {
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
csrf = csrfToken ctx
|
||||
page_title = "Upload Theme"
|
||||
|}
|
||||
|> viewForTheme "admin" "upload-theme" next ctx
|
||||
}
|
||||
|
||||
/// Update the name and version for a theme based on the version.txt file, if present
|
||||
let private updateNameAndVersion (theme : Theme) (zip : ZipArchive) = backgroundTask {
|
||||
let now () = DateTime.UtcNow.ToString "yyyyMMdd.HHmm"
|
||||
match zip.Entries |> Seq.filter (fun it -> it.FullName = "version.txt") |> Seq.tryHead with
|
||||
| Some versionItem ->
|
||||
use versionFile = new StreamReader(versionItem.Open ())
|
||||
let! versionText = versionFile.ReadToEndAsync ()
|
||||
let parts = versionText.Trim().Replace("\r", "").Split "\n"
|
||||
let displayName = if parts[0] > "" then parts[0] else ThemeId.toString theme.id
|
||||
let version = if parts.Length > 1 && parts[1] > "" then parts[1] else now ()
|
||||
return { theme with name = displayName; version = version }
|
||||
| None ->
|
||||
return { theme with name = ThemeId.toString theme.id; version = now () }
|
||||
}
|
||||
|
||||
/// Delete all theme assets, and remove templates from theme
|
||||
let private checkForCleanLoad (theme : Theme) cleanLoad (data : IData) = backgroundTask {
|
||||
if cleanLoad then
|
||||
do! data.ThemeAsset.deleteByTheme theme.id
|
||||
return { theme with templates = [] }
|
||||
else
|
||||
return theme
|
||||
}
|
||||
|
||||
/// Update the theme with all templates from the ZIP archive
|
||||
let private updateTemplates (theme : Theme) (zip : ZipArchive) = backgroundTask {
|
||||
let tasks =
|
||||
zip.Entries
|
||||
|> Seq.filter (fun it -> it.Name.EndsWith ".liquid")
|
||||
|> Seq.map (fun templateItem -> backgroundTask {
|
||||
use templateFile = new StreamReader (templateItem.Open ())
|
||||
let! template = templateFile.ReadToEndAsync ()
|
||||
return { name = templateItem.Name.Replace (".liquid", ""); text = template }
|
||||
})
|
||||
let! templates = Task.WhenAll tasks
|
||||
return
|
||||
templates
|
||||
|> Array.fold (fun t template ->
|
||||
{ t with templates = template :: (t.templates |> List.filter (fun it -> it.name <> template.name)) })
|
||||
theme
|
||||
}
|
||||
|
||||
/// Update theme assets from the ZIP archive
|
||||
let private updateAssets themeId (zip : ZipArchive) (data : IData) = backgroundTask {
|
||||
for asset in zip.Entries |> Seq.filter (fun it -> it.FullName.StartsWith "wwwroot") do
|
||||
let assetName = asset.FullName.Replace ("wwwroot/", "")
|
||||
if assetName <> "" && not (assetName.EndsWith "/") then
|
||||
use stream = new MemoryStream ()
|
||||
do! asset.Open().CopyToAsync stream
|
||||
do! data.ThemeAsset.save
|
||||
{ id = ThemeAssetId (themeId, assetName)
|
||||
updatedOn = asset.LastWriteTime.DateTime
|
||||
data = stream.ToArray ()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the theme name from the file name given
|
||||
let getThemeName (fileName : string) =
|
||||
let themeName = fileName.Split(".").[0].ToLowerInvariant().Replace (" ", "-")
|
||||
if Regex.IsMatch (themeName, """^[a-z0-9\-]+$""") then Ok themeName else Error $"Theme name {fileName} is invalid"
|
||||
|
||||
/// Load a theme from the given stream, which should contain a ZIP archive
|
||||
let loadThemeFromZip themeName file clean (data : IData) = backgroundTask {
|
||||
use zip = new ZipArchive (file, ZipArchiveMode.Read)
|
||||
let themeId = ThemeId themeName
|
||||
let! theme = backgroundTask {
|
||||
match! data.Theme.findById themeId with
|
||||
| Some t -> return t
|
||||
| None -> return { Theme.empty with id = themeId }
|
||||
}
|
||||
let! theme = updateNameAndVersion theme zip
|
||||
let! theme = checkForCleanLoad theme clean data
|
||||
let! theme = updateTemplates theme zip
|
||||
do! data.Theme.save theme
|
||||
do! updateAssets themeId zip data
|
||||
}
|
||||
|
||||
// POST /admin/theme/update
|
||||
let updateTheme : HttpHandler = fun next ctx -> task {
|
||||
if ctx.Request.HasFormContentType && ctx.Request.Form.Files.Count > 0 then
|
||||
let themeFile = Seq.head ctx.Request.Form.Files
|
||||
match getThemeName themeFile.FileName with
|
||||
| Ok themeName when themeName <> "admin" ->
|
||||
let data = ctx.Data
|
||||
use stream = new MemoryStream ()
|
||||
do! themeFile.CopyToAsync stream
|
||||
do! loadThemeFromZip themeName stream true data
|
||||
do! ThemeAssetCache.refreshTheme (ThemeId themeName) data
|
||||
do! addMessage ctx { UserMessage.success with message = "Theme updated successfully" }
|
||||
return! redirectToGet (WebLog.relativeUrl ctx.WebLog (Permalink "admin/dashboard")) next ctx
|
||||
| Ok _ ->
|
||||
do! addMessage ctx { UserMessage.error with message = "You may not replace the admin theme" }
|
||||
return! redirectToGet (WebLog.relativeUrl ctx.WebLog (Permalink "admin/theme/update")) next ctx
|
||||
| Error message ->
|
||||
do! addMessage ctx { UserMessage.error with message = message }
|
||||
return! redirectToGet (WebLog.relativeUrl ctx.WebLog (Permalink "admin/theme/update")) next ctx
|
||||
else
|
||||
return! RequestErrors.BAD_REQUEST "Bad request" next ctx
|
||||
}
|
||||
|
||||
// -- WEB LOG SETTINGS --
|
||||
|
||||
open System.Collections.Generic
|
||||
|
||||
// GET /admin/settings
|
||||
let settings : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
let data = ctx.Data
|
||||
let! allPages = data.Page.all webLog.id
|
||||
let! themes = data.Theme.all ()
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
csrf = csrfToken ctx
|
||||
model = SettingsModel.fromWebLog webLog
|
||||
pages =
|
||||
seq {
|
||||
KeyValuePair.Create ("posts", "- First Page of Posts -")
|
||||
yield! allPages
|
||||
|> List.sortBy (fun p -> p.title.ToLower ())
|
||||
|> List.map (fun p -> KeyValuePair.Create (PageId.toString p.id, p.title))
|
||||
}
|
||||
|> Array.ofSeq
|
||||
themes = themes
|
||||
|> Seq.ofList
|
||||
|> Seq.map (fun it ->
|
||||
KeyValuePair.Create (ThemeId.toString it.id, $"{it.name} (v{it.version})"))
|
||||
|> Array.ofSeq
|
||||
web_log = webLog
|
||||
page_title = "Web Log Settings"
|
||||
|}
|
||||
|> viewForTheme "admin" "settings" next ctx
|
||||
}
|
||||
|
||||
// POST /admin/settings
|
||||
let saveSettings : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
let data = ctx.Data
|
||||
let! model = ctx.BindFormAsync<SettingsModel> ()
|
||||
match! data.WebLog.findById webLog.id with
|
||||
| Some webLog ->
|
||||
let webLog = model.update webLog
|
||||
do! data.WebLog.updateSettings webLog
|
||||
|
||||
// Update cache
|
||||
WebLogCache.set webLog
|
||||
|
||||
do! addMessage ctx { UserMessage.success with message = "Web log settings saved successfully" }
|
||||
return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/settings")) next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
22
src/MyWebLog/Handlers/Error.fs
Normal file
22
src/MyWebLog/Handlers/Error.fs
Normal file
|
@ -0,0 +1,22 @@
|
|||
/// Handlers for error conditions
|
||||
module MyWebLog.Handlers.Error
|
||||
|
||||
open System.Net
|
||||
open System.Threading.Tasks
|
||||
open Giraffe
|
||||
open Microsoft.AspNetCore.Http
|
||||
open MyWebLog
|
||||
|
||||
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response
|
||||
let notAuthorized : HttpHandler = fun next ctx -> task {
|
||||
if ctx.Request.Method = "GET" then
|
||||
let returnUrl = WebUtility.UrlEncode ctx.Request.Path
|
||||
return!
|
||||
redirectTo false (WebLog.relativeUrl ctx.WebLog (Permalink $"user/log-on?returnUrl={returnUrl}")) next ctx
|
||||
else
|
||||
return! (setStatusCode 401 >=> fun _ _ -> Task.FromResult<HttpContext option> None) next ctx
|
||||
}
|
||||
|
||||
/// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there
|
||||
let notFound : HttpHandler =
|
||||
setStatusCode 404 >=> text "Not found"
|
459
src/MyWebLog/Handlers/Feed.fs
Normal file
459
src/MyWebLog/Handlers/Feed.fs
Normal file
|
@ -0,0 +1,459 @@
|
|||
/// Functions to support generating RSS feeds
|
||||
module MyWebLog.Handlers.Feed
|
||||
|
||||
open System
|
||||
open System.IO
|
||||
open System.Net
|
||||
open System.ServiceModel.Syndication
|
||||
open System.Text.RegularExpressions
|
||||
open System.Xml
|
||||
open Giraffe
|
||||
open Microsoft.AspNetCore.Http
|
||||
open MyWebLog
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
// ~~ FEED GENERATION ~~
|
||||
|
||||
/// The type of feed to generate
|
||||
type FeedType =
|
||||
| StandardFeed of string
|
||||
| CategoryFeed of CategoryId * string
|
||||
| TagFeed of string * string
|
||||
| Custom of CustomFeed * string
|
||||
|
||||
/// Derive the type of RSS feed requested
|
||||
let deriveFeedType (ctx : HttpContext) feedPath : (FeedType * int) option =
|
||||
let webLog = ctx.WebLog
|
||||
let debug = debug "Feed" ctx
|
||||
let name = $"/{webLog.rss.feedName}"
|
||||
let postCount = defaultArg webLog.rss.itemsInFeed webLog.postsPerPage
|
||||
debug (fun () -> $"Considering potential feed for {feedPath} (configured feed name {name})")
|
||||
// Standard feed
|
||||
match webLog.rss.feedEnabled && feedPath = name with
|
||||
| true ->
|
||||
debug (fun () -> "Found standard feed")
|
||||
Some (StandardFeed feedPath, postCount)
|
||||
| false ->
|
||||
// Category and tag feeds are handled by defined routes; check for custom feed
|
||||
match webLog.rss.customFeeds
|
||||
|> List.tryFind (fun it -> feedPath.EndsWith (Permalink.toString it.path)) with
|
||||
| Some feed ->
|
||||
debug (fun () -> "Found custom feed")
|
||||
Some (Custom (feed, feedPath),
|
||||
feed.podcast |> Option.map (fun p -> p.itemsInFeed) |> Option.defaultValue postCount)
|
||||
| None ->
|
||||
debug (fun () -> $"No matching feed found")
|
||||
None
|
||||
|
||||
/// Determine the function to retrieve posts for the given feed
|
||||
let private getFeedPosts ctx feedType =
|
||||
let childIds catId =
|
||||
let cat = CategoryCache.get ctx |> Array.find (fun c -> c.id = CategoryId.toString catId)
|
||||
getCategoryIds cat.slug ctx
|
||||
let data = ctx.Data
|
||||
match feedType with
|
||||
| StandardFeed _ -> data.Post.findPageOfPublishedPosts ctx.WebLog.id 1
|
||||
| CategoryFeed (catId, _) -> data.Post.findPageOfCategorizedPosts ctx.WebLog.id (childIds catId) 1
|
||||
| TagFeed (tag, _) -> data.Post.findPageOfTaggedPosts ctx.WebLog.id tag 1
|
||||
| Custom (feed, _) ->
|
||||
match feed.source with
|
||||
| Category catId -> data.Post.findPageOfCategorizedPosts ctx.WebLog.id (childIds catId) 1
|
||||
| Tag tag -> data.Post.findPageOfTaggedPosts ctx.WebLog.id tag 1
|
||||
|
||||
/// Strip HTML from a string
|
||||
let private stripHtml text = WebUtility.HtmlDecode <| Regex.Replace (text, "<(.|\n)*?>", "")
|
||||
|
||||
/// XML namespaces for building RSS feeds
|
||||
[<RequireQualifiedAccess>]
|
||||
module private Namespace =
|
||||
|
||||
/// Enables encoded (HTML) content
|
||||
let content = "http://purl.org/rss/1.0/modules/content/"
|
||||
|
||||
/// The dc XML namespace
|
||||
let dc = "http://purl.org/dc/elements/1.1/"
|
||||
|
||||
/// iTunes elements
|
||||
let iTunes = "http://www.itunes.com/dtds/podcast-1.0.dtd"
|
||||
|
||||
/// Enables chapters
|
||||
let psc = "http://podlove.org/simple-chapters/"
|
||||
|
||||
/// Enables another "subscribe" option
|
||||
let rawVoice = "http://www.rawvoice.com/rawvoiceRssModule/"
|
||||
|
||||
/// Create a feed item from the given post
|
||||
let private toFeedItem webLog (authors : MetaItem list) (cats : DisplayCategory[]) (tagMaps : TagMap list)
|
||||
(post : Post) =
|
||||
let plainText =
|
||||
let endingP = post.text.IndexOf "</p>"
|
||||
stripHtml <| if endingP >= 0 then post.text[..(endingP - 1)] else post.text
|
||||
let item = SyndicationItem (
|
||||
Id = WebLog.absoluteUrl webLog post.permalink,
|
||||
Title = TextSyndicationContent.CreateHtmlContent post.title,
|
||||
PublishDate = DateTimeOffset post.publishedOn.Value,
|
||||
LastUpdatedTime = DateTimeOffset post.updatedOn,
|
||||
Content = TextSyndicationContent.CreatePlaintextContent plainText)
|
||||
item.AddPermalink (Uri item.Id)
|
||||
|
||||
let xmlDoc = XmlDocument ()
|
||||
|
||||
let encoded =
|
||||
let txt =
|
||||
post.text
|
||||
.Replace("src=\"/", $"src=\"{webLog.urlBase}/")
|
||||
.Replace ("href=\"/", $"href=\"{webLog.urlBase}/")
|
||||
let it = xmlDoc.CreateElement ("content", "encoded", Namespace.content)
|
||||
let _ = it.AppendChild (xmlDoc.CreateCDataSection txt)
|
||||
it
|
||||
item.ElementExtensions.Add encoded
|
||||
|
||||
item.Authors.Add (SyndicationPerson (
|
||||
Name = (authors |> List.find (fun a -> a.name = WebLogUserId.toString post.authorId)).value))
|
||||
[ post.categoryIds
|
||||
|> List.map (fun catId ->
|
||||
let cat = cats |> Array.find (fun c -> c.id = CategoryId.toString catId)
|
||||
SyndicationCategory (cat.name, WebLog.absoluteUrl webLog (Permalink $"category/{cat.slug}/"), cat.name))
|
||||
post.tags
|
||||
|> List.map (fun tag ->
|
||||
let urlTag =
|
||||
match tagMaps |> List.tryFind (fun tm -> tm.tag = tag) with
|
||||
| Some tm -> tm.urlValue
|
||||
| None -> tag.Replace (" ", "+")
|
||||
SyndicationCategory (tag, WebLog.absoluteUrl webLog (Permalink $"tag/{urlTag}/"), $"{tag} (tag)"))
|
||||
]
|
||||
|> List.concat
|
||||
|> List.iter item.Categories.Add
|
||||
item
|
||||
|
||||
/// Add episode information to a podcast feed item
|
||||
let private addEpisode webLog (feed : CustomFeed) (post : Post) (item : SyndicationItem) =
|
||||
let podcast = Option.get feed.podcast
|
||||
let meta name = post.metadata |> List.tryFind (fun it -> it.name = name)
|
||||
let value (item : MetaItem) = item.value
|
||||
let epMediaUrl =
|
||||
match (meta >> Option.get >> value) "episode_media_file" with
|
||||
| link when link.StartsWith "http" -> link
|
||||
| link when Option.isSome podcast.mediaBaseUrl -> $"{podcast.mediaBaseUrl.Value}{link}"
|
||||
| link -> WebLog.absoluteUrl webLog (Permalink link)
|
||||
let epMediaType =
|
||||
match meta "episode_media_type", podcast.defaultMediaType with
|
||||
| Some epType, _ -> Some epType.value
|
||||
| None, Some defType -> Some defType
|
||||
| _ -> None
|
||||
let epImageUrl =
|
||||
match defaultArg ((meta >> Option.map value) "episode_image") (Permalink.toString podcast.imageUrl) with
|
||||
| link when link.StartsWith "http" -> link
|
||||
| link -> WebLog.absoluteUrl webLog (Permalink link)
|
||||
let epExplicit =
|
||||
try
|
||||
(meta >> Option.map (value >> ExplicitRating.parse)) "episode_explicit"
|
||||
|> Option.defaultValue podcast.explicit
|
||||
|> ExplicitRating.toString
|
||||
with :? ArgumentException -> ExplicitRating.toString podcast.explicit
|
||||
|
||||
let xmlDoc = XmlDocument ()
|
||||
let enclosure =
|
||||
let it = xmlDoc.CreateElement "enclosure"
|
||||
it.SetAttribute ("url", epMediaUrl)
|
||||
meta "episode_media_length" |> Option.iter (fun len -> it.SetAttribute ("length", len.value))
|
||||
epMediaType |> Option.iter (fun typ -> it.SetAttribute ("type", typ))
|
||||
it
|
||||
let image =
|
||||
let it = xmlDoc.CreateElement ("itunes", "image", Namespace.iTunes)
|
||||
it.SetAttribute ("href", epImageUrl)
|
||||
it
|
||||
|
||||
item.ElementExtensions.Add enclosure
|
||||
item.ElementExtensions.Add image
|
||||
item.ElementExtensions.Add ("creator", Namespace.dc, podcast.displayedAuthor)
|
||||
item.ElementExtensions.Add ("author", Namespace.iTunes, podcast.displayedAuthor)
|
||||
item.ElementExtensions.Add ("explicit", Namespace.iTunes, epExplicit)
|
||||
meta "episode_subtitle"
|
||||
|> Option.iter (fun it -> item.ElementExtensions.Add ("subtitle", Namespace.iTunes, it.value))
|
||||
meta "episode_duration"
|
||||
|> Option.iter (fun it -> item.ElementExtensions.Add ("duration", Namespace.iTunes, it.value))
|
||||
|
||||
if post.metadata |> List.exists (fun it -> it.name = "chapter") then
|
||||
try
|
||||
let chapters = xmlDoc.CreateElement ("psc", "chapters", Namespace.psc)
|
||||
chapters.SetAttribute ("version", "1.2")
|
||||
|
||||
post.metadata
|
||||
|> List.filter (fun it -> it.name = "chapter")
|
||||
|> List.map (fun it ->
|
||||
TimeSpan.Parse (it.value.Split(" ")[0]), it.value.Substring (it.value.IndexOf(" ") + 1))
|
||||
|> List.sortBy fst
|
||||
|> List.iter (fun chap ->
|
||||
let chapter = xmlDoc.CreateElement ("psc", "chapter", Namespace.psc)
|
||||
chapter.SetAttribute ("start", (fst chap).ToString "hh:mm:ss")
|
||||
chapter.SetAttribute ("title", snd chap)
|
||||
chapters.AppendChild chapter |> ignore)
|
||||
|
||||
item.ElementExtensions.Add chapters
|
||||
with _ -> ()
|
||||
item
|
||||
|
||||
/// Add a namespace to the feed
|
||||
let private addNamespace (feed : SyndicationFeed) alias nsUrl =
|
||||
feed.AttributeExtensions.Add (XmlQualifiedName (alias, "http://www.w3.org/2000/xmlns/"), nsUrl)
|
||||
|
||||
/// Add items to the top of the feed required for podcasts
|
||||
let private addPodcast webLog (rssFeed : SyndicationFeed) (feed : CustomFeed) =
|
||||
let addChild (doc : XmlDocument) ns prefix name value (elt : XmlElement) =
|
||||
let child =
|
||||
if ns = "" then doc.CreateElement name else doc.CreateElement (prefix, name, ns)
|
||||
|> elt.AppendChild
|
||||
child.InnerText <- value
|
||||
elt
|
||||
|
||||
let podcast = Option.get feed.podcast
|
||||
let feedUrl = WebLog.absoluteUrl webLog feed.path
|
||||
let imageUrl =
|
||||
match podcast.imageUrl with
|
||||
| Permalink link when link.StartsWith "http" -> link
|
||||
| Permalink _ -> WebLog.absoluteUrl webLog podcast.imageUrl
|
||||
|
||||
let xmlDoc = XmlDocument ()
|
||||
|
||||
[ "dc", Namespace.dc; "itunes", Namespace.iTunes; "psc", Namespace.psc; "rawvoice", Namespace.rawVoice ]
|
||||
|> List.iter (fun (alias, nsUrl) -> addNamespace rssFeed alias nsUrl)
|
||||
|
||||
let categorization =
|
||||
let it = xmlDoc.CreateElement ("itunes", "category", Namespace.iTunes)
|
||||
it.SetAttribute ("text", podcast.iTunesCategory)
|
||||
podcast.iTunesSubcategory
|
||||
|> Option.iter (fun subCat ->
|
||||
let subCatElt = xmlDoc.CreateElement ("itunes", "category", Namespace.iTunes)
|
||||
subCatElt.SetAttribute ("text", subCat)
|
||||
it.AppendChild subCatElt |> ignore)
|
||||
it
|
||||
let image =
|
||||
[ "title", podcast.title
|
||||
"url", imageUrl
|
||||
"link", feedUrl
|
||||
]
|
||||
|> List.fold (fun elt (name, value) -> addChild xmlDoc "" "" name value elt) (xmlDoc.CreateElement "image")
|
||||
let iTunesImage =
|
||||
let it = xmlDoc.CreateElement ("itunes", "image", Namespace.iTunes)
|
||||
it.SetAttribute ("href", imageUrl)
|
||||
it
|
||||
let owner =
|
||||
[ "name", podcast.displayedAuthor
|
||||
"email", podcast.email
|
||||
]
|
||||
|> List.fold (fun elt (name, value) -> addChild xmlDoc Namespace.iTunes "itunes" name value elt)
|
||||
(xmlDoc.CreateElement ("itunes", "owner", Namespace.iTunes))
|
||||
let rawVoice =
|
||||
let it = xmlDoc.CreateElement ("rawvoice", "subscribe", Namespace.rawVoice)
|
||||
it.SetAttribute ("feed", feedUrl)
|
||||
it.SetAttribute ("itunes", "")
|
||||
it
|
||||
|
||||
rssFeed.ElementExtensions.Add image
|
||||
rssFeed.ElementExtensions.Add owner
|
||||
rssFeed.ElementExtensions.Add categorization
|
||||
rssFeed.ElementExtensions.Add iTunesImage
|
||||
rssFeed.ElementExtensions.Add rawVoice
|
||||
rssFeed.ElementExtensions.Add ("summary", Namespace.iTunes, podcast.summary)
|
||||
rssFeed.ElementExtensions.Add ("author", Namespace.iTunes, podcast.displayedAuthor)
|
||||
rssFeed.ElementExtensions.Add ("explicit", Namespace.iTunes, ExplicitRating.toString podcast.explicit)
|
||||
podcast.subtitle |> Option.iter (fun sub -> rssFeed.ElementExtensions.Add ("subtitle", Namespace.iTunes, sub))
|
||||
|
||||
/// Get the feed's self reference and non-feed link
|
||||
let private selfAndLink webLog feedType ctx =
|
||||
let withoutFeed (it : string) = Permalink (it.Replace ($"/{webLog.rss.feedName}", ""))
|
||||
match feedType with
|
||||
| StandardFeed path
|
||||
| CategoryFeed (_, path)
|
||||
| TagFeed (_, path) -> Permalink path[1..], withoutFeed path
|
||||
| Custom (feed, _) ->
|
||||
match feed.source with
|
||||
| Category (CategoryId catId) ->
|
||||
feed.path, Permalink $"category/{(CategoryCache.get ctx |> Array.find (fun c -> c.id = catId)).slug}"
|
||||
| Tag tag -> feed.path, Permalink $"""tag/{tag.Replace(" ", "+")}/"""
|
||||
|
||||
/// Set the title and description of the feed based on its source
|
||||
let private setTitleAndDescription feedType (webLog : WebLog) (cats : DisplayCategory[]) (feed : SyndicationFeed) =
|
||||
let cleanText opt def = TextSyndicationContent (stripHtml (defaultArg opt def))
|
||||
match feedType with
|
||||
| StandardFeed _ ->
|
||||
feed.Title <- cleanText None webLog.name
|
||||
feed.Description <- cleanText webLog.subtitle webLog.name
|
||||
| CategoryFeed (CategoryId catId, _) ->
|
||||
let cat = cats |> Array.find (fun it -> it.id = catId)
|
||||
feed.Title <- cleanText None $"""{webLog.name} - "{stripHtml cat.name}" Category"""
|
||||
feed.Description <- cleanText cat.description $"""Posts categorized under "{cat.name}" """
|
||||
| TagFeed (tag, _) ->
|
||||
feed.Title <- cleanText None $"""{webLog.name} - "{tag}" Tag"""
|
||||
feed.Description <- cleanText None $"""Posts with the "{tag}" tag"""
|
||||
| Custom (custom, _) ->
|
||||
match custom.podcast with
|
||||
| Some podcast ->
|
||||
feed.Title <- cleanText None podcast.title
|
||||
feed.Description <- cleanText podcast.subtitle podcast.title
|
||||
| None ->
|
||||
match custom.source with
|
||||
| Category (CategoryId catId) ->
|
||||
let cat = cats |> Array.find (fun it -> it.id = catId)
|
||||
feed.Title <- cleanText None $"""{webLog.name} - "{stripHtml cat.name}" Category"""
|
||||
feed.Description <- cleanText cat.description $"""Posts categorized under "{cat.name}" """
|
||||
| Tag tag ->
|
||||
feed.Title <- cleanText None $"""{webLog.name} - "{tag}" Tag"""
|
||||
feed.Description <- cleanText None $"""Posts with the "{tag}" tag"""
|
||||
|
||||
/// Create a feed with a known non-zero-length list of posts
|
||||
let createFeed (feedType : FeedType) posts : HttpHandler = fun next ctx -> backgroundTask {
|
||||
let webLog = ctx.WebLog
|
||||
let data = ctx.Data
|
||||
let! authors = getAuthors webLog posts data
|
||||
let! tagMaps = getTagMappings webLog posts data
|
||||
let cats = CategoryCache.get ctx
|
||||
let podcast = match feedType with Custom (feed, _) when Option.isSome feed.podcast -> Some feed | _ -> None
|
||||
let self, link = selfAndLink webLog feedType ctx
|
||||
|
||||
let toItem post =
|
||||
let item = toFeedItem webLog authors cats tagMaps post
|
||||
match podcast with
|
||||
| Some feed when post.metadata |> List.exists (fun it -> it.name = "episode_media_file") ->
|
||||
addEpisode webLog feed post item
|
||||
| Some _ ->
|
||||
warn "Feed" ctx $"[{webLog.name} {Permalink.toString self}] \"{stripHtml post.title}\" has no media"
|
||||
item
|
||||
| _ -> item
|
||||
|
||||
let feed = SyndicationFeed ()
|
||||
addNamespace feed "content" Namespace.content
|
||||
setTitleAndDescription feedType webLog cats feed
|
||||
|
||||
feed.LastUpdatedTime <- (List.head posts).updatedOn |> DateTimeOffset
|
||||
feed.Generator <- generator ctx
|
||||
feed.Items <- posts |> Seq.ofList |> Seq.map toItem
|
||||
feed.Language <- "en"
|
||||
feed.Id <- WebLog.absoluteUrl webLog link
|
||||
webLog.rss.copyright |> Option.iter (fun copy -> feed.Copyright <- TextSyndicationContent copy)
|
||||
|
||||
feed.Links.Add (SyndicationLink (Uri (WebLog.absoluteUrl webLog self), "self", "", "application/rss+xml", 0L))
|
||||
feed.ElementExtensions.Add ("link", "", WebLog.absoluteUrl webLog link)
|
||||
|
||||
podcast |> Option.iter (addPodcast webLog feed)
|
||||
|
||||
use mem = new MemoryStream ()
|
||||
use xml = XmlWriter.Create mem
|
||||
feed.SaveAsRss20 xml
|
||||
xml.Close ()
|
||||
|
||||
let _ = mem.Seek (0L, SeekOrigin.Begin)
|
||||
let rdr = new StreamReader(mem)
|
||||
let! output = rdr.ReadToEndAsync ()
|
||||
|
||||
return! (setHttpHeader "Content-Type" "text/xml" >=> setStatusCode 200 >=> setBodyFromString output) next ctx
|
||||
}
|
||||
|
||||
// GET {any-prescribed-feed}
|
||||
let generate (feedType : FeedType) postCount : HttpHandler = fun next ctx -> backgroundTask {
|
||||
match! getFeedPosts ctx feedType postCount with
|
||||
| posts when List.length posts > 0 -> return! createFeed feedType posts next ctx
|
||||
| _ -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// ~~ FEED ADMINISTRATION ~~
|
||||
|
||||
open DotLiquid
|
||||
|
||||
// GET: /admin/rss/settings
|
||||
let editSettings : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
let feeds =
|
||||
webLog.rss.customFeeds
|
||||
|> List.map (DisplayCustomFeed.fromFeed (CategoryCache.get ctx))
|
||||
|> Array.ofList
|
||||
return! Hash.FromAnonymousObject
|
||||
{| csrf = csrfToken ctx
|
||||
page_title = "RSS Settings"
|
||||
model = EditRssModel.fromRssOptions webLog.rss
|
||||
custom_feeds = feeds
|
||||
|}
|
||||
|> viewForTheme "admin" "rss-settings" next ctx
|
||||
}
|
||||
|
||||
// POST: /admin/rss/settings
|
||||
let saveSettings : HttpHandler = fun next ctx -> task {
|
||||
let data = ctx.Data
|
||||
let! model = ctx.BindFormAsync<EditRssModel> ()
|
||||
match! data.WebLog.findById ctx.WebLog.id with
|
||||
| Some webLog ->
|
||||
let webLog = { webLog with rss = model.updateOptions webLog.rss }
|
||||
do! data.WebLog.updateRssOptions webLog
|
||||
WebLogCache.set webLog
|
||||
do! addMessage ctx { UserMessage.success with message = "RSS settings updated successfully" }
|
||||
return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/settings/rss")) next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// GET: /admin/rss/{id}/edit
|
||||
let editCustomFeed feedId : HttpHandler = fun next ctx -> task {
|
||||
let customFeed =
|
||||
match feedId with
|
||||
| "new" -> Some { CustomFeed.empty with id = CustomFeedId "new" }
|
||||
| _ -> ctx.WebLog.rss.customFeeds |> List.tryFind (fun f -> f.id = CustomFeedId feedId)
|
||||
match customFeed with
|
||||
| Some f ->
|
||||
return! Hash.FromAnonymousObject
|
||||
{| csrf = csrfToken ctx
|
||||
page_title = $"""{if feedId = "new" then "Add" else "Edit"} Custom RSS Feed"""
|
||||
model = EditCustomFeedModel.fromFeed f
|
||||
categories = CategoryCache.get ctx
|
||||
|}
|
||||
|> viewForTheme "admin" "custom-feed-edit" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST: /admin/rss/save
|
||||
let saveCustomFeed : HttpHandler = fun next ctx -> task {
|
||||
let data = ctx.Data
|
||||
match! data.WebLog.findById ctx.WebLog.id with
|
||||
| Some webLog ->
|
||||
let! model = ctx.BindFormAsync<EditCustomFeedModel> ()
|
||||
let theFeed =
|
||||
match model.id with
|
||||
| "new" -> Some { CustomFeed.empty with id = CustomFeedId.create () }
|
||||
| _ -> webLog.rss.customFeeds |> List.tryFind (fun it -> CustomFeedId.toString it.id = model.id)
|
||||
match theFeed with
|
||||
| Some feed ->
|
||||
let feeds = model.updateFeed feed :: (webLog.rss.customFeeds |> List.filter (fun it -> it.id <> feed.id))
|
||||
let webLog = { webLog with rss = { webLog.rss with customFeeds = feeds } }
|
||||
do! data.WebLog.updateRssOptions webLog
|
||||
WebLogCache.set webLog
|
||||
do! addMessage ctx {
|
||||
UserMessage.success with
|
||||
message = $"""Successfully {if model.id = "new" then "add" else "sav"}ed custom feed"""
|
||||
}
|
||||
let nextUrl = $"admin/settings/rss/{CustomFeedId.toString feed.id}/edit"
|
||||
return! redirectToGet (WebLog.relativeUrl webLog (Permalink nextUrl)) next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/rss/{id}/delete
|
||||
let deleteCustomFeed feedId : HttpHandler = fun next ctx -> task {
|
||||
let data = ctx.Data
|
||||
match! data.WebLog.findById ctx.WebLog.id with
|
||||
| Some webLog ->
|
||||
let customId = CustomFeedId feedId
|
||||
if webLog.rss.customFeeds |> List.exists (fun f -> f.id = customId) then
|
||||
let webLog = {
|
||||
webLog with
|
||||
rss = {
|
||||
webLog.rss with
|
||||
customFeeds = webLog.rss.customFeeds |> List.filter (fun f -> f.id <> customId)
|
||||
}
|
||||
}
|
||||
do! data.WebLog.updateRssOptions webLog
|
||||
WebLogCache.set webLog
|
||||
do! addMessage ctx { UserMessage.success with message = "Custom feed deleted successfully" }
|
||||
else
|
||||
do! addMessage ctx { UserMessage.warning with message = "Custom feed not found; no action taken" }
|
||||
return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/settings/rss")) next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
256
src/MyWebLog/Handlers/Helpers.fs
Normal file
256
src/MyWebLog/Handlers/Helpers.fs
Normal file
|
@ -0,0 +1,256 @@
|
|||
[<AutoOpen>]
|
||||
module private MyWebLog.Handlers.Helpers
|
||||
|
||||
open System.Text.Json
|
||||
open Microsoft.AspNetCore.Http
|
||||
|
||||
/// Session extensions to get and set objects
|
||||
type ISession with
|
||||
|
||||
/// Set an item in the session
|
||||
member this.Set<'T> (key, item : 'T) =
|
||||
this.SetString (key, JsonSerializer.Serialize item)
|
||||
|
||||
/// Get an item from the session
|
||||
member this.Get<'T> key =
|
||||
match this.GetString key with
|
||||
| null -> None
|
||||
| item -> Some (JsonSerializer.Deserialize<'T> item)
|
||||
|
||||
|
||||
/// The HTTP item key for loading the session
|
||||
let private sessionLoadedKey = "session-loaded"
|
||||
|
||||
/// Load the session if it has not been loaded already; ensures async access but not excessive loading
|
||||
let private loadSession (ctx : HttpContext) = task {
|
||||
if not (ctx.Items.ContainsKey sessionLoadedKey) then
|
||||
do! ctx.Session.LoadAsync ()
|
||||
ctx.Items.Add (sessionLoadedKey, "yes")
|
||||
}
|
||||
|
||||
/// Ensure that the session is committed
|
||||
let private commitSession (ctx : HttpContext) = task {
|
||||
if ctx.Items.ContainsKey sessionLoadedKey then do! ctx.Session.CommitAsync ()
|
||||
}
|
||||
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
/// Add a message to the user's session
|
||||
let addMessage (ctx : HttpContext) message = task {
|
||||
do! loadSession ctx
|
||||
let msg = match ctx.Session.Get<UserMessage list> "messages" with Some it -> it | None -> []
|
||||
ctx.Session.Set ("messages", message :: msg)
|
||||
}
|
||||
|
||||
/// Get any messages from the user's session, removing them in the process
|
||||
let messages (ctx : HttpContext) = task {
|
||||
do! loadSession ctx
|
||||
match ctx.Session.Get<UserMessage list> "messages" with
|
||||
| Some msg ->
|
||||
ctx.Session.Remove "messages"
|
||||
return msg |> (List.rev >> Array.ofList)
|
||||
| None -> return [||]
|
||||
}
|
||||
|
||||
/// Hold variable for the configured generator string
|
||||
let mutable private generatorString : string option = None
|
||||
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
|
||||
/// Get the generator string
|
||||
let generator (ctx : HttpContext) =
|
||||
match generatorString with
|
||||
| Some gen -> gen
|
||||
| None ->
|
||||
let cfg = ctx.RequestServices.GetRequiredService<IConfiguration> ()
|
||||
generatorString <-
|
||||
match Option.ofObj cfg["Generator"] with
|
||||
| Some gen -> Some gen
|
||||
| None -> Some "generator not configured"
|
||||
generatorString.Value
|
||||
|
||||
open MyWebLog
|
||||
open DotLiquid
|
||||
|
||||
/// Either get the web log from the hash, or get it from the cache and add it to the hash
|
||||
let private deriveWebLogFromHash (hash : Hash) (ctx : HttpContext) =
|
||||
if hash.ContainsKey "web_log" then () else hash.Add ("web_log", ctx.WebLog)
|
||||
hash["web_log"] :?> WebLog
|
||||
|
||||
open Giraffe
|
||||
open Giraffe.Htmx
|
||||
open Giraffe.ViewEngine
|
||||
|
||||
/// htmx script tag
|
||||
let private htmxScript = RenderView.AsString.htmlNode Htmx.Script.minified
|
||||
|
||||
/// Populate the DotLiquid hash with standard information
|
||||
let private populateHash hash ctx = task {
|
||||
// Don't need the web log, but this adds it to the hash if the function is called directly
|
||||
let _ = deriveWebLogFromHash hash ctx
|
||||
let! messages = messages ctx
|
||||
hash.Add ("logged_on", ctx.User.Identity.IsAuthenticated)
|
||||
hash.Add ("page_list", PageListCache.get ctx)
|
||||
hash.Add ("current_page", ctx.Request.Path.Value.Substring 1)
|
||||
hash.Add ("messages", messages)
|
||||
hash.Add ("generator", generator ctx)
|
||||
hash.Add ("htmx_script", htmxScript)
|
||||
|
||||
do! commitSession ctx
|
||||
}
|
||||
|
||||
/// Render a view for the specified theme, using the specified template, layout, and hash
|
||||
let viewForTheme theme template next ctx = fun (hash : Hash) -> task {
|
||||
do! populateHash hash ctx
|
||||
|
||||
// NOTE: DotLiquid does not support {% render %} or {% include %} in its templates, so we will do a 2-pass render;
|
||||
// the net effect is a "layout" capability similar to Razor or Pug
|
||||
|
||||
// Render view content...
|
||||
let! contentTemplate = TemplateCache.get theme template ctx.Data
|
||||
hash.Add ("content", contentTemplate.Render hash)
|
||||
|
||||
// ...then render that content with its layout
|
||||
let isHtmx = ctx.Request.IsHtmx && not ctx.Request.IsHtmxRefresh
|
||||
let! layoutTemplate = TemplateCache.get theme (if isHtmx then "layout-partial" else "layout") ctx.Data
|
||||
|
||||
return! htmlString (layoutTemplate.Render hash) next ctx
|
||||
}
|
||||
|
||||
/// Render a bare view for the specified theme, using the specified template and hash
|
||||
let bareForTheme theme template next ctx = fun (hash : Hash) -> task {
|
||||
do! populateHash hash ctx
|
||||
|
||||
// Bare templates are rendered with layout-bare
|
||||
let! contentTemplate = TemplateCache.get theme template ctx.Data
|
||||
hash.Add ("content", contentTemplate.Render hash)
|
||||
|
||||
let! layoutTemplate = TemplateCache.get theme "layout-bare" ctx.Data
|
||||
|
||||
// add messages as HTTP headers
|
||||
let messages = hash["messages"] :?> UserMessage[]
|
||||
let actions = seq {
|
||||
yield!
|
||||
messages
|
||||
|> Array.map (fun m ->
|
||||
match m.detail with
|
||||
| Some detail -> $"{m.level}|||{m.message}|||{detail}"
|
||||
| None -> $"{m.level}|||{m.message}"
|
||||
|> setHttpHeader "X-Message")
|
||||
withHxNoPush
|
||||
htmlString (layoutTemplate.Render hash)
|
||||
}
|
||||
|
||||
return! (actions |> Seq.reduce (>=>)) next ctx
|
||||
}
|
||||
|
||||
/// Return a view for the web log's default theme
|
||||
let themedView template next ctx = fun (hash : Hash) -> task {
|
||||
return! viewForTheme (deriveWebLogFromHash hash ctx).themePath template next ctx hash
|
||||
}
|
||||
|
||||
/// Redirect after doing some action; commits session and issues a temporary redirect
|
||||
let redirectToGet url : HttpHandler = fun next ctx -> task {
|
||||
do! commitSession ctx
|
||||
return! redirectTo false url next ctx
|
||||
}
|
||||
|
||||
open System.Security.Claims
|
||||
|
||||
/// Get the user ID for the current request
|
||||
let userId (ctx : HttpContext) =
|
||||
WebLogUserId (ctx.User.Claims |> Seq.find (fun c -> c.Type = ClaimTypes.NameIdentifier)).Value
|
||||
|
||||
open Microsoft.AspNetCore.Antiforgery
|
||||
|
||||
/// Get the Anti-CSRF service
|
||||
let private antiForgery (ctx : HttpContext) = ctx.RequestServices.GetRequiredService<IAntiforgery> ()
|
||||
|
||||
/// Get the cross-site request forgery token set
|
||||
let csrfToken (ctx : HttpContext) =
|
||||
(antiForgery ctx).GetAndStoreTokens ctx
|
||||
|
||||
/// Validate the cross-site request forgery token in the current request
|
||||
let validateCsrf : HttpHandler = fun next ctx -> task {
|
||||
match! (antiForgery ctx).IsRequestValidAsync ctx with
|
||||
| true -> return! next ctx
|
||||
| false -> return! RequestErrors.BAD_REQUEST "CSRF token invalid" next ctx
|
||||
}
|
||||
|
||||
/// Require a user to be logged on
|
||||
let requireUser : HttpHandler = requiresAuthentication Error.notAuthorized
|
||||
|
||||
open System.Collections.Generic
|
||||
open MyWebLog.Data
|
||||
|
||||
/// Get the templates available for the current web log's theme (in a key/value pair list)
|
||||
let templatesForTheme (ctx : HttpContext) (typ : string) = backgroundTask {
|
||||
match! ctx.Data.Theme.findByIdWithoutText (ThemeId ctx.WebLog.themePath) with
|
||||
| Some theme ->
|
||||
return seq {
|
||||
KeyValuePair.Create ("", $"- Default (single-{typ}) -")
|
||||
yield!
|
||||
theme.templates
|
||||
|> Seq.ofList
|
||||
|> Seq.filter (fun it -> it.name.EndsWith $"-{typ}" && it.name <> $"single-{typ}")
|
||||
|> Seq.map (fun it -> KeyValuePair.Create (it.name, it.name))
|
||||
}
|
||||
|> Array.ofSeq
|
||||
| None -> return [| KeyValuePair.Create ("", $"- Default (single-{typ}) -") |]
|
||||
}
|
||||
|
||||
/// Get all authors for a list of posts as metadata items
|
||||
let getAuthors (webLog : WebLog) (posts : Post list) (data : IData) =
|
||||
posts
|
||||
|> List.map (fun p -> p.authorId)
|
||||
|> List.distinct
|
||||
|> data.WebLogUser.findNames webLog.id
|
||||
|
||||
/// Get all tag mappings for a list of posts as metadata items
|
||||
let getTagMappings (webLog : WebLog) (posts : Post list) (data : IData) =
|
||||
posts
|
||||
|> List.map (fun p -> p.tags)
|
||||
|> List.concat
|
||||
|> List.distinct
|
||||
|> fun tags -> data.TagMap.findMappingForTags tags webLog.id
|
||||
|
||||
/// Get all category IDs for the given slug (includes owned subcategories)
|
||||
let getCategoryIds slug ctx =
|
||||
let allCats = CategoryCache.get ctx
|
||||
let cat = allCats |> Array.find (fun cat -> cat.slug = slug)
|
||||
// Category pages include posts in subcategories
|
||||
allCats
|
||||
|> Seq.ofArray
|
||||
|> Seq.filter (fun c -> c.id = cat.id || Array.contains cat.name c.parentNames)
|
||||
|> Seq.map (fun c -> CategoryId c.id)
|
||||
|> List.ofSeq
|
||||
|
||||
open Microsoft.Extensions.Logging
|
||||
|
||||
/// Log level for debugging
|
||||
let mutable private debugEnabled : bool option = None
|
||||
|
||||
/// Is debug enabled for handlers?
|
||||
let private isDebugEnabled (ctx : HttpContext) =
|
||||
match debugEnabled with
|
||||
| Some flag -> flag
|
||||
| None ->
|
||||
let fac = ctx.RequestServices.GetRequiredService<ILoggerFactory> ()
|
||||
let log = fac.CreateLogger "MyWebLog.Handlers"
|
||||
debugEnabled <- Some (log.IsEnabled LogLevel.Debug)
|
||||
debugEnabled.Value
|
||||
|
||||
/// Log a debug message
|
||||
let debug (name : string) ctx msg =
|
||||
if isDebugEnabled ctx then
|
||||
let fac = ctx.RequestServices.GetRequiredService<ILoggerFactory> ()
|
||||
let log = fac.CreateLogger $"MyWebLog.Handlers.{name}"
|
||||
log.LogDebug (msg ())
|
||||
|
||||
/// Log a warning message
|
||||
let warn (name : string) (ctx : HttpContext) msg =
|
||||
let fac = ctx.RequestServices.GetRequiredService<ILoggerFactory> ()
|
||||
let log = fac.CreateLogger $"MyWebLog.Handlers.{name}"
|
||||
log.LogWarning msg
|
||||
|
365
src/MyWebLog/Handlers/Post.fs
Normal file
365
src/MyWebLog/Handlers/Post.fs
Normal file
|
@ -0,0 +1,365 @@
|
|||
/// Handlers to manipulate posts
|
||||
module MyWebLog.Handlers.Post
|
||||
|
||||
open System
|
||||
open MyWebLog
|
||||
|
||||
/// Parse a slug and page number from an "everything else" URL
|
||||
let private parseSlugAndPage webLog (slugAndPage : string seq) =
|
||||
let fullPath = slugAndPage |> Seq.head
|
||||
let slugPath = slugAndPage |> Seq.skip 1 |> Seq.head
|
||||
let slugs, isFeed =
|
||||
let feedName = $"/{webLog.rss.feedName}"
|
||||
let notBlank = Array.filter (fun it -> it <> "")
|
||||
if ( (webLog.rss.categoryEnabled && fullPath.StartsWith "/category/")
|
||||
|| (webLog.rss.tagEnabled && fullPath.StartsWith "/tag/" ))
|
||||
&& slugPath.EndsWith feedName then
|
||||
notBlank (slugPath.Replace(feedName, "").Split "/"), true
|
||||
else
|
||||
notBlank (slugPath.Split "/"), false
|
||||
let pageIdx = Array.IndexOf (slugs, "page")
|
||||
let pageNbr =
|
||||
match pageIdx with
|
||||
| -1 -> Some 1
|
||||
| idx when idx + 2 = slugs.Length -> Some (int slugs[pageIdx + 1])
|
||||
| _ -> None
|
||||
let slugParts = if pageIdx > 0 then Array.truncate pageIdx slugs else slugs
|
||||
pageNbr, String.Join ("/", slugParts), isFeed
|
||||
|
||||
/// The type of post list being prepared
|
||||
type ListType =
|
||||
| AdminList
|
||||
| CategoryList
|
||||
| PostList
|
||||
| SinglePost
|
||||
| TagList
|
||||
|
||||
open System.Threading.Tasks
|
||||
open DotLiquid
|
||||
open MyWebLog.Data
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
/// Convert a list of posts into items ready to be displayed
|
||||
let preparePostList webLog posts listType (url : string) pageNbr perPage ctx (data : IData) = task {
|
||||
let! authors = getAuthors webLog posts data
|
||||
let! tagMappings = getTagMappings webLog posts data
|
||||
let relUrl it = Some <| WebLog.relativeUrl webLog (Permalink it)
|
||||
let postItems =
|
||||
posts
|
||||
|> Seq.ofList
|
||||
|> Seq.truncate perPage
|
||||
|> Seq.map (PostListItem.fromPost webLog)
|
||||
|> Array.ofSeq
|
||||
let! olderPost, newerPost =
|
||||
match listType with
|
||||
| SinglePost ->
|
||||
let post = List.head posts
|
||||
let dateTime = defaultArg post.publishedOn post.updatedOn
|
||||
data.Post.findSurroundingPosts webLog.id dateTime
|
||||
| _ -> Task.FromResult (None, None)
|
||||
let newerLink =
|
||||
match listType, pageNbr with
|
||||
| SinglePost, _ -> newerPost |> Option.map (fun p -> Permalink.toString p.permalink)
|
||||
| _, 1 -> None
|
||||
| PostList, 2 when webLog.defaultPage = "posts" -> Some ""
|
||||
| PostList, _ -> relUrl $"page/{pageNbr - 1}"
|
||||
| CategoryList, 2 -> relUrl $"category/{url}/"
|
||||
| CategoryList, _ -> relUrl $"category/{url}/page/{pageNbr - 1}"
|
||||
| TagList, 2 -> relUrl $"tag/{url}/"
|
||||
| TagList, _ -> relUrl $"tag/{url}/page/{pageNbr - 1}"
|
||||
| AdminList, 2 -> relUrl "admin/posts"
|
||||
| AdminList, _ -> relUrl $"admin/posts/page/{pageNbr - 1}"
|
||||
let olderLink =
|
||||
match listType, List.length posts > perPage with
|
||||
| SinglePost, _ -> olderPost |> Option.map (fun p -> Permalink.toString p.permalink)
|
||||
| _, false -> None
|
||||
| PostList, true -> relUrl $"page/{pageNbr + 1}"
|
||||
| CategoryList, true -> relUrl $"category/{url}/page/{pageNbr + 1}"
|
||||
| TagList, true -> relUrl $"tag/{url}/page/{pageNbr + 1}"
|
||||
| AdminList, true -> relUrl $"admin/posts/page/{pageNbr + 1}"
|
||||
let model =
|
||||
{ posts = postItems
|
||||
authors = authors
|
||||
subtitle = None
|
||||
newerLink = newerLink
|
||||
newerName = newerPost |> Option.map (fun p -> p.title)
|
||||
olderLink = olderLink
|
||||
olderName = olderPost |> Option.map (fun p -> p.title)
|
||||
}
|
||||
return Hash.FromAnonymousObject {|
|
||||
model = model
|
||||
categories = CategoryCache.get ctx
|
||||
tag_mappings = tagMappings
|
||||
is_post = match listType with SinglePost -> true | _ -> false
|
||||
|}
|
||||
}
|
||||
|
||||
open Giraffe
|
||||
|
||||
// GET /page/{pageNbr}
|
||||
let pageOfPosts pageNbr : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
let data = ctx.Data
|
||||
let! posts = data.Post.findPageOfPublishedPosts webLog.id pageNbr webLog.postsPerPage
|
||||
let! hash = preparePostList webLog posts PostList "" pageNbr webLog.postsPerPage ctx data
|
||||
let title =
|
||||
match pageNbr, webLog.defaultPage with
|
||||
| 1, "posts" -> None
|
||||
| _, "posts" -> Some $"Page {pageNbr}"
|
||||
| _, _ -> Some $"Page {pageNbr} « Posts"
|
||||
match title with Some ttl -> hash.Add ("page_title", ttl) | None -> ()
|
||||
if pageNbr = 1 && webLog.defaultPage = "posts" then hash.Add ("is_home", true)
|
||||
return! themedView "index" next ctx hash
|
||||
}
|
||||
|
||||
// GET /page/{pageNbr}/
|
||||
let redirectToPageOfPosts (pageNbr : int) : HttpHandler = fun next ctx ->
|
||||
redirectTo true (WebLog.relativeUrl ctx.WebLog (Permalink $"page/{pageNbr}")) next ctx
|
||||
|
||||
// GET /category/{slug}/
|
||||
// GET /category/{slug}/page/{pageNbr}
|
||||
let pageOfCategorizedPosts slugAndPage : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
let data = ctx.Data
|
||||
match parseSlugAndPage webLog slugAndPage with
|
||||
| Some pageNbr, slug, isFeed ->
|
||||
match CategoryCache.get ctx |> Array.tryFind (fun cat -> cat.slug = slug) with
|
||||
| Some cat when isFeed ->
|
||||
return! Feed.generate (Feed.CategoryFeed ((CategoryId cat.id), $"category/{slug}/{webLog.rss.feedName}"))
|
||||
(defaultArg webLog.rss.itemsInFeed webLog.postsPerPage) next ctx
|
||||
| Some cat ->
|
||||
// Category pages include posts in subcategories
|
||||
match! data.Post.findPageOfCategorizedPosts webLog.id (getCategoryIds slug ctx) pageNbr webLog.postsPerPage
|
||||
with
|
||||
| posts when List.length posts > 0 ->
|
||||
let! hash = preparePostList webLog posts CategoryList cat.slug pageNbr webLog.postsPerPage ctx data
|
||||
let pgTitle = if pageNbr = 1 then "" else $""" <small class="archive-pg-nbr">(Page {pageNbr})</small>"""
|
||||
hash.Add ("page_title", $"{cat.name}: Category Archive{pgTitle}")
|
||||
hash.Add ("subtitle", defaultArg cat.description "")
|
||||
hash.Add ("is_category", true)
|
||||
hash.Add ("is_category_home", (pageNbr = 1))
|
||||
hash.Add ("slug", slug)
|
||||
return! themedView "index" next ctx hash
|
||||
| _ -> return! Error.notFound next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
| None, _, _ -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
open System.Web
|
||||
|
||||
// GET /tag/{tag}/
|
||||
// GET /tag/{tag}/page/{pageNbr}
|
||||
let pageOfTaggedPosts slugAndPage : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
let data = ctx.Data
|
||||
match parseSlugAndPage webLog slugAndPage with
|
||||
| Some pageNbr, rawTag, isFeed ->
|
||||
let urlTag = HttpUtility.UrlDecode rawTag
|
||||
let! tag = backgroundTask {
|
||||
match! data.TagMap.findByUrlValue urlTag webLog.id with
|
||||
| Some m -> return m.tag
|
||||
| None -> return urlTag
|
||||
}
|
||||
if isFeed then
|
||||
return! Feed.generate (Feed.TagFeed (tag, $"tag/{rawTag}/{webLog.rss.feedName}"))
|
||||
(defaultArg webLog.rss.itemsInFeed webLog.postsPerPage) next ctx
|
||||
else
|
||||
match! data.Post.findPageOfTaggedPosts webLog.id tag pageNbr webLog.postsPerPage with
|
||||
| posts when List.length posts > 0 ->
|
||||
let! hash = preparePostList webLog posts TagList rawTag pageNbr webLog.postsPerPage ctx data
|
||||
let pgTitle = if pageNbr = 1 then "" else $""" <small class="archive-pg-nbr">(Page {pageNbr})</small>"""
|
||||
hash.Add ("page_title", $"Posts Tagged “{tag}”{pgTitle}")
|
||||
hash.Add ("is_tag", true)
|
||||
hash.Add ("is_tag_home", (pageNbr = 1))
|
||||
hash.Add ("slug", rawTag)
|
||||
return! themedView "index" next ctx hash
|
||||
// Other systems use hyphens for spaces; redirect if this is an old tag link
|
||||
| _ ->
|
||||
let spacedTag = tag.Replace ("-", " ")
|
||||
match! data.Post.findPageOfTaggedPosts webLog.id spacedTag pageNbr 1 with
|
||||
| posts when List.length posts > 0 ->
|
||||
let endUrl = if pageNbr = 1 then "" else $"page/{pageNbr}"
|
||||
return!
|
||||
redirectTo true
|
||||
(WebLog.relativeUrl webLog (Permalink $"""tag/{spacedTag.Replace (" ", "+")}/{endUrl}"""))
|
||||
next ctx
|
||||
| _ -> return! Error.notFound next ctx
|
||||
| None, _, _ -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// GET /
|
||||
let home : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
match webLog.defaultPage with
|
||||
| "posts" -> return! pageOfPosts 1 next ctx
|
||||
| pageId ->
|
||||
match! ctx.Data.Page.findById (PageId pageId) webLog.id with
|
||||
| Some page ->
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
page = DisplayPage.fromPage webLog page
|
||||
categories = CategoryCache.get ctx
|
||||
page_title = page.title
|
||||
is_home = true
|
||||
|}
|
||||
|> themedView (defaultArg page.template "single-page") next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// GET /admin/posts
|
||||
// GET /admin/posts/page/{pageNbr}
|
||||
let all pageNbr : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
let data = ctx.Data
|
||||
let! posts = data.Post.findPageOfPosts webLog.id pageNbr 25
|
||||
let! hash = preparePostList webLog posts AdminList "" pageNbr 25 ctx data
|
||||
hash.Add ("page_title", "Posts")
|
||||
hash.Add ("csrf", csrfToken ctx)
|
||||
return! viewForTheme "admin" "post-list" next ctx hash
|
||||
}
|
||||
|
||||
// GET /admin/post/{id}/edit
|
||||
let edit postId : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
let data = ctx.Data
|
||||
let! result = task {
|
||||
match postId with
|
||||
| "new" -> return Some ("Write a New Post", { Post.empty with id = PostId "new" })
|
||||
| _ ->
|
||||
match! data.Post.findFullById (PostId postId) webLog.id with
|
||||
| Some post -> return Some ("Edit Post", post)
|
||||
| None -> return None
|
||||
}
|
||||
match result with
|
||||
| Some (title, post) ->
|
||||
let! cats = data.Category.findAllForView webLog.id
|
||||
let! templates = templatesForTheme ctx "post"
|
||||
let model = EditPostModel.fromPost webLog post
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
csrf = csrfToken ctx
|
||||
model = model
|
||||
metadata = Array.zip model.metaNames model.metaValues
|
||||
|> Array.mapi (fun idx (name, value) -> [| string idx; name; value |])
|
||||
page_title = title
|
||||
templates = templates
|
||||
categories = cats
|
||||
|}
|
||||
|> viewForTheme "admin" "post-edit" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// GET /admin/post/{id}/permalinks
|
||||
let editPermalinks postId : HttpHandler = fun next ctx -> task {
|
||||
match! ctx.Data.Post.findFullById (PostId postId) ctx.WebLog.id with
|
||||
| Some post ->
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
csrf = csrfToken ctx
|
||||
model = ManagePermalinksModel.fromPost post
|
||||
page_title = $"Manage Prior Permalinks"
|
||||
|}
|
||||
|> viewForTheme "admin" "permalinks" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/post/permalinks
|
||||
let savePermalinks : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
let! model = ctx.BindFormAsync<ManagePermalinksModel> ()
|
||||
let links = model.prior |> Array.map Permalink |> List.ofArray
|
||||
match! ctx.Data.Post.updatePriorPermalinks (PostId model.id) webLog.id links with
|
||||
| true ->
|
||||
do! addMessage ctx { UserMessage.success with message = "Post permalinks saved successfully" }
|
||||
return! redirectToGet (WebLog.relativeUrl webLog (Permalink $"admin/post/{model.id}/permalinks")) next ctx
|
||||
| false -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/post/{id}/delete
|
||||
let delete postId : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
match! ctx.Data.Post.delete (PostId postId) webLog.id with
|
||||
| true -> do! addMessage ctx { UserMessage.success with message = "Post deleted successfully" }
|
||||
| false -> do! addMessage ctx { UserMessage.error with message = "Post not found; nothing deleted" }
|
||||
return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/posts")) next ctx
|
||||
}
|
||||
|
||||
#nowarn "3511"
|
||||
|
||||
// POST /admin/post/save
|
||||
let save : HttpHandler = fun next ctx -> task {
|
||||
let! model = ctx.BindFormAsync<EditPostModel> ()
|
||||
let webLog = ctx.WebLog
|
||||
let data = ctx.Data
|
||||
let now = DateTime.UtcNow
|
||||
let! pst = task {
|
||||
match model.postId with
|
||||
| "new" ->
|
||||
return Some
|
||||
{ Post.empty with
|
||||
id = PostId.create ()
|
||||
webLogId = webLog.id
|
||||
authorId = userId ctx
|
||||
}
|
||||
| postId -> return! data.Post.findFullById (PostId postId) webLog.id
|
||||
}
|
||||
match pst with
|
||||
| Some post ->
|
||||
let revision = { asOf = now; text = MarkupText.parse $"{model.source}: {model.text}" }
|
||||
// Detect a permalink change, and add the prior one to the prior list
|
||||
let post =
|
||||
match Permalink.toString post.permalink with
|
||||
| "" -> post
|
||||
| link when link = model.permalink -> post
|
||||
| _ -> { post with priorPermalinks = post.permalink :: post.priorPermalinks }
|
||||
let post =
|
||||
{ post with
|
||||
title = model.title
|
||||
permalink = Permalink model.permalink
|
||||
publishedOn = if model.doPublish then Some now else post.publishedOn
|
||||
updatedOn = now
|
||||
text = MarkupText.toHtml revision.text
|
||||
tags = model.tags.Split ","
|
||||
|> Seq.ofArray
|
||||
|> Seq.map (fun it -> it.Trim().ToLower ())
|
||||
|> Seq.filter (fun it -> it <> "")
|
||||
|> Seq.sort
|
||||
|> List.ofSeq
|
||||
template = match model.template.Trim () with "" -> None | tmpl -> Some tmpl
|
||||
categoryIds = model.categoryIds |> Array.map CategoryId |> List.ofArray
|
||||
status = if model.doPublish then Published else post.status
|
||||
metadata = Seq.zip model.metaNames model.metaValues
|
||||
|> Seq.filter (fun it -> fst it > "")
|
||||
|> Seq.map (fun it -> { name = fst it; value = snd it })
|
||||
|> Seq.sortBy (fun it -> $"{it.name.ToLower ()} {it.value.ToLower ()}")
|
||||
|> List.ofSeq
|
||||
revisions = match post.revisions |> List.tryHead with
|
||||
| Some r when r.text = revision.text -> post.revisions
|
||||
| _ -> revision :: post.revisions
|
||||
}
|
||||
let post =
|
||||
match model.setPublished with
|
||||
| true ->
|
||||
let dt = WebLog.utcTime webLog model.pubOverride.Value
|
||||
match model.setUpdated with
|
||||
| true ->
|
||||
{ post with
|
||||
publishedOn = Some dt
|
||||
updatedOn = dt
|
||||
revisions = [ { (List.head post.revisions) with asOf = dt } ]
|
||||
}
|
||||
| false -> { post with publishedOn = Some dt }
|
||||
| false -> post
|
||||
do! (if model.postId = "new" then data.Post.add else data.Post.update) post
|
||||
// If the post was published or its categories changed, refresh the category cache
|
||||
if model.doPublish
|
||||
|| not (pst.Value.categoryIds
|
||||
|> List.append post.categoryIds
|
||||
|> List.distinct
|
||||
|> List.length = List.length pst.Value.categoryIds) then
|
||||
do! CategoryCache.update ctx
|
||||
do! addMessage ctx { UserMessage.success with message = "Post saved successfully" }
|
||||
return!
|
||||
redirectToGet (WebLog.relativeUrl webLog (Permalink $"admin/post/{PostId.toString post.id}/edit")) next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
240
src/MyWebLog/Handlers/Routes.fs
Normal file
240
src/MyWebLog/Handlers/Routes.fs
Normal file
|
@ -0,0 +1,240 @@
|
|||
/// Routes for this application
|
||||
module MyWebLog.Handlers.Routes
|
||||
|
||||
open Giraffe
|
||||
open Microsoft.AspNetCore.Http
|
||||
open MyWebLog
|
||||
|
||||
/// Module to resolve routes that do not match any other known route (web blog content)
|
||||
module CatchAll =
|
||||
|
||||
open DotLiquid
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
/// Sequence where the first returned value is the proper handler for the link
|
||||
let private deriveAction (ctx : HttpContext) : HttpHandler seq =
|
||||
let webLog = ctx.WebLog
|
||||
let data = ctx.Data
|
||||
let debug = debug "Routes.CatchAll" ctx
|
||||
let textLink =
|
||||
let _, extra = WebLog.hostAndPath webLog
|
||||
let url = string ctx.Request.Path
|
||||
(if extra = "" then url else url.Substring extra.Length).ToLowerInvariant ()
|
||||
let await it = (Async.AwaitTask >> Async.RunSynchronously) it
|
||||
seq {
|
||||
debug (fun () -> $"Considering URL {textLink}")
|
||||
// Home page directory without the directory slash
|
||||
if textLink = "" then yield redirectTo true (WebLog.relativeUrl webLog Permalink.empty)
|
||||
let permalink = Permalink (textLink.Substring 1)
|
||||
// Current post
|
||||
match data.Post.findByPermalink permalink webLog.id |> await with
|
||||
| Some post ->
|
||||
debug (fun () -> $"Found post by permalink")
|
||||
let model = Post.preparePostList webLog [ post ] Post.ListType.SinglePost "" 1 1 ctx data |> await
|
||||
model.Add ("page_title", post.title)
|
||||
yield fun next ctx -> themedView (defaultArg post.template "single-post") next ctx model
|
||||
| None -> ()
|
||||
// Current page
|
||||
match data.Page.findByPermalink permalink webLog.id |> await with
|
||||
| Some page ->
|
||||
debug (fun () -> $"Found page by permalink")
|
||||
yield fun next ctx ->
|
||||
Hash.FromAnonymousObject {|
|
||||
page = DisplayPage.fromPage webLog page
|
||||
categories = CategoryCache.get ctx
|
||||
page_title = page.title
|
||||
is_page = true
|
||||
|}
|
||||
|> themedView (defaultArg page.template "single-page") next ctx
|
||||
| None -> ()
|
||||
// RSS feed
|
||||
match Feed.deriveFeedType ctx textLink with
|
||||
| Some (feedType, postCount) ->
|
||||
debug (fun () -> $"Found RSS feed")
|
||||
yield Feed.generate feedType postCount
|
||||
| None -> ()
|
||||
// Post differing only by trailing slash
|
||||
let altLink =
|
||||
Permalink (if textLink.EndsWith "/" then textLink[1..textLink.Length - 2] else $"{textLink[1..]}/")
|
||||
match data.Post.findByPermalink altLink webLog.id |> await with
|
||||
| Some post ->
|
||||
debug (fun () -> $"Found post by trailing-slash-agnostic permalink")
|
||||
yield redirectTo true (WebLog.relativeUrl webLog post.permalink)
|
||||
| None -> ()
|
||||
// Page differing only by trailing slash
|
||||
match data.Page.findByPermalink altLink webLog.id |> await with
|
||||
| Some page ->
|
||||
debug (fun () -> $"Found page by trailing-slash-agnostic permalink")
|
||||
yield redirectTo true (WebLog.relativeUrl webLog page.permalink)
|
||||
| None -> ()
|
||||
// Prior post
|
||||
match data.Post.findCurrentPermalink [ permalink; altLink ] webLog.id |> await with
|
||||
| Some link ->
|
||||
debug (fun () -> $"Found post by prior permalink")
|
||||
yield redirectTo true (WebLog.relativeUrl webLog link)
|
||||
| None -> ()
|
||||
// Prior page
|
||||
match data.Page.findCurrentPermalink [ permalink; altLink ] webLog.id |> await with
|
||||
| Some link ->
|
||||
debug (fun () -> $"Found page by prior permalink")
|
||||
yield redirectTo true (WebLog.relativeUrl webLog link)
|
||||
| None -> ()
|
||||
debug (fun () -> $"No content found")
|
||||
}
|
||||
|
||||
// GET {all-of-the-above}
|
||||
let route : HttpHandler = fun next ctx -> task {
|
||||
match deriveAction ctx |> Seq.tryHead with
|
||||
| Some handler -> return! handler next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
|
||||
/// Serve theme assets
|
||||
module Asset =
|
||||
|
||||
open System
|
||||
open Microsoft.AspNetCore.Http.Headers
|
||||
open Microsoft.AspNetCore.StaticFiles
|
||||
open Microsoft.Net.Http.Headers
|
||||
|
||||
/// Determine if the asset has been modified since the date/time specified by the If-Modified-Since header
|
||||
let private checkModified asset (ctx : HttpContext) : HttpHandler option =
|
||||
match ctx.Request.Headers.IfModifiedSince with
|
||||
| it when it.Count < 1 -> None
|
||||
| it ->
|
||||
if asset.updatedOn > DateTime.Parse it[0] then
|
||||
None
|
||||
else
|
||||
Some (setStatusCode 304 >=> setBodyFromString "Not Modified")
|
||||
|
||||
/// An instance of ASP.NET Core's file extension to MIME type converter
|
||||
let private mimeMap = FileExtensionContentTypeProvider ()
|
||||
|
||||
// GET /theme/{theme}/{**path}
|
||||
let serveAsset (urlParts : string seq) : HttpHandler = fun next ctx -> task {
|
||||
let path = urlParts |> Seq.skip 1 |> Seq.head
|
||||
match! ctx.Data.ThemeAsset.findById (ThemeAssetId.ofString path) with
|
||||
| Some asset ->
|
||||
match checkModified asset ctx with
|
||||
| Some threeOhFour -> return! threeOhFour next ctx
|
||||
| None ->
|
||||
let mimeType =
|
||||
match mimeMap.TryGetContentType path with
|
||||
| true, typ -> typ
|
||||
| false, _ -> "application/octet-stream"
|
||||
let headers = ResponseHeaders ctx.Response.Headers
|
||||
headers.LastModified <- Some (DateTimeOffset asset.updatedOn) |> Option.toNullable
|
||||
headers.ContentType <- MediaTypeHeaderValue mimeType
|
||||
headers.CacheControl <-
|
||||
let hdr = CacheControlHeaderValue()
|
||||
hdr.MaxAge <- Some (TimeSpan.FromDays 30) |> Option.toNullable
|
||||
hdr
|
||||
return! setBody asset.data next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
|
||||
/// The primary myWebLog router
|
||||
let router : HttpHandler = choose [
|
||||
GET >=> choose [
|
||||
route "/" >=> Post.home
|
||||
]
|
||||
subRoute "/admin" (requireUser >=> choose [
|
||||
GET >=> choose [
|
||||
subRoute "/categor" (choose [
|
||||
route "ies" >=> Admin.listCategories
|
||||
route "ies/bare" >=> Admin.listCategoriesBare
|
||||
routef "y/%s/edit" Admin.editCategory
|
||||
])
|
||||
route "/dashboard" >=> Admin.dashboard
|
||||
subRoute "/page" (choose [
|
||||
route "s" >=> Admin.listPages 1
|
||||
routef "s/page/%i" Admin.listPages
|
||||
routef "/%s/edit" Admin.editPage
|
||||
routef "/%s/permalinks" Admin.editPagePermalinks
|
||||
])
|
||||
subRoute "/post" (choose [
|
||||
route "s" >=> Post.all 1
|
||||
routef "s/page/%i" Post.all
|
||||
routef "/%s/edit" Post.edit
|
||||
routef "/%s/permalinks" Post.editPermalinks
|
||||
])
|
||||
subRoute "/settings" (choose [
|
||||
route "" >=> Admin.settings
|
||||
subRoute "/rss" (choose [
|
||||
route "" >=> Feed.editSettings
|
||||
routef "/%s/edit" Feed.editCustomFeed
|
||||
])
|
||||
subRoute "/tag-mapping" (choose [
|
||||
route "s" >=> Admin.tagMappings
|
||||
route "s/bare" >=> Admin.tagMappingsBare
|
||||
routef "/%s/edit" Admin.editMapping
|
||||
])
|
||||
])
|
||||
route "/theme/update" >=> Admin.themeUpdatePage
|
||||
route "/user/edit" >=> User.edit
|
||||
]
|
||||
POST >=> validateCsrf >=> choose [
|
||||
subRoute "/category" (choose [
|
||||
route "/save" >=> Admin.saveCategory
|
||||
routef "/%s/delete" Admin.deleteCategory
|
||||
])
|
||||
subRoute "/page" (choose [
|
||||
route "/save" >=> Admin.savePage
|
||||
route "/permalinks" >=> Admin.savePagePermalinks
|
||||
routef "/%s/delete" Admin.deletePage
|
||||
])
|
||||
subRoute "/post" (choose [
|
||||
route "/save" >=> Post.save
|
||||
route "/permalinks" >=> Post.savePermalinks
|
||||
routef "/%s/delete" Post.delete
|
||||
])
|
||||
subRoute "/settings" (choose [
|
||||
route "" >=> Admin.saveSettings
|
||||
subRoute "/rss" (choose [
|
||||
route "" >=> Feed.saveSettings
|
||||
route "/save" >=> Feed.saveCustomFeed
|
||||
routef "/%s/delete" Feed.deleteCustomFeed
|
||||
])
|
||||
subRoute "/tag-mapping" (choose [
|
||||
route "/save" >=> Admin.saveMapping
|
||||
routef "/%s/delete" Admin.deleteMapping
|
||||
])
|
||||
])
|
||||
route "/theme/update" >=> Admin.updateTheme
|
||||
route "/user/save" >=> User.save
|
||||
]
|
||||
])
|
||||
GET_HEAD >=> routexp "/category/(.*)" Post.pageOfCategorizedPosts
|
||||
GET_HEAD >=> routef "/page/%i" Post.pageOfPosts
|
||||
GET_HEAD >=> routef "/page/%i/" Post.redirectToPageOfPosts
|
||||
GET_HEAD >=> routexp "/tag/(.*)" Post.pageOfTaggedPosts
|
||||
GET_HEAD >=> routexp "/themes/(.*)" Asset.serveAsset
|
||||
subRoute "/user" (choose [
|
||||
GET_HEAD >=> choose [
|
||||
route "/log-on" >=> User.logOn None
|
||||
route "/log-off" >=> User.logOff
|
||||
]
|
||||
POST >=> validateCsrf >=> choose [
|
||||
route "/log-on" >=> User.doLogOn
|
||||
]
|
||||
])
|
||||
GET_HEAD >=> CatchAll.route
|
||||
Error.notFound
|
||||
]
|
||||
|
||||
/// Wrap a router in a sub-route
|
||||
let routerWithPath extraPath : HttpHandler =
|
||||
subRoute extraPath router
|
||||
|
||||
/// Handler to apply Giraffe routing with a possible sub-route
|
||||
let handleRoute : HttpHandler = fun next ctx -> task {
|
||||
let _, extraPath = WebLog.hostAndPath ctx.WebLog
|
||||
return! (if extraPath = "" then router else routerWithPath extraPath) next ctx
|
||||
}
|
||||
|
||||
open Giraffe.EndpointRouting
|
||||
|
||||
/// Endpoint-routed handler to deal with sub-routes
|
||||
let endpoint = [ route "{**url}" handleRoute ]
|
118
src/MyWebLog/Handlers/User.fs
Normal file
118
src/MyWebLog/Handlers/User.fs
Normal file
|
@ -0,0 +1,118 @@
|
|||
/// Handlers to manipulate users
|
||||
module MyWebLog.Handlers.User
|
||||
|
||||
open System
|
||||
open System.Security.Cryptography
|
||||
open System.Text
|
||||
|
||||
/// Hash a password for a given user
|
||||
let hashedPassword (plainText : string) (email : string) (salt : Guid) =
|
||||
let allSalt = Array.concat [ salt.ToByteArray (); Encoding.UTF8.GetBytes email ]
|
||||
use alg = new Rfc2898DeriveBytes (plainText, allSalt, 2_048)
|
||||
Convert.ToBase64String (alg.GetBytes 64)
|
||||
|
||||
open DotLiquid
|
||||
open Giraffe
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
// GET /user/log-on
|
||||
let logOn returnUrl : HttpHandler = fun next ctx -> task {
|
||||
let returnTo =
|
||||
match returnUrl with
|
||||
| Some _ -> returnUrl
|
||||
| None ->
|
||||
match ctx.Request.Query.ContainsKey "returnUrl" with
|
||||
| true -> Some ctx.Request.Query["returnUrl"].[0]
|
||||
| false -> None
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
model = { LogOnModel.empty with returnTo = returnTo }
|
||||
page_title = "Log On"
|
||||
csrf = csrfToken ctx
|
||||
|}
|
||||
|> viewForTheme "admin" "log-on" next ctx
|
||||
}
|
||||
|
||||
open System.Security.Claims
|
||||
open Microsoft.AspNetCore.Authentication
|
||||
open Microsoft.AspNetCore.Authentication.Cookies
|
||||
open MyWebLog
|
||||
|
||||
// POST /user/log-on
|
||||
let doLogOn : HttpHandler = fun next ctx -> task {
|
||||
let! model = ctx.BindFormAsync<LogOnModel> ()
|
||||
let webLog = ctx.WebLog
|
||||
match! ctx.Data.WebLogUser.findByEmail model.emailAddress webLog.id with
|
||||
| Some user when user.passwordHash = hashedPassword model.password user.userName user.salt ->
|
||||
let claims = seq {
|
||||
Claim (ClaimTypes.NameIdentifier, WebLogUserId.toString user.id)
|
||||
Claim (ClaimTypes.Name, $"{user.firstName} {user.lastName}")
|
||||
Claim (ClaimTypes.GivenName, user.preferredName)
|
||||
Claim (ClaimTypes.Role, user.authorizationLevel.ToString ())
|
||||
}
|
||||
let identity = ClaimsIdentity (claims, CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
|
||||
do! ctx.SignInAsync (identity.AuthenticationType, ClaimsPrincipal identity,
|
||||
AuthenticationProperties (IssuedUtc = DateTimeOffset.UtcNow))
|
||||
do! addMessage ctx
|
||||
{ UserMessage.success with message = $"Logged on successfully | Welcome to {webLog.name}!" }
|
||||
return! redirectToGet (defaultArg model.returnTo (WebLog.relativeUrl webLog (Permalink "admin/dashboard")))
|
||||
next ctx
|
||||
| _ ->
|
||||
do! addMessage ctx { UserMessage.error with message = "Log on attempt unsuccessful" }
|
||||
return! logOn model.returnTo next ctx
|
||||
}
|
||||
|
||||
// GET /user/log-off
|
||||
let logOff : HttpHandler = fun next ctx -> task {
|
||||
do! ctx.SignOutAsync CookieAuthenticationDefaults.AuthenticationScheme
|
||||
do! addMessage ctx { UserMessage.info with message = "Log off successful" }
|
||||
return! redirectToGet (WebLog.relativeUrl ctx.WebLog Permalink.empty) next ctx
|
||||
}
|
||||
|
||||
/// Display the user edit page, with information possibly filled in
|
||||
let private showEdit (hash : Hash) : HttpHandler = fun next ctx -> task {
|
||||
hash.Add ("page_title", "Edit Your Information")
|
||||
hash.Add ("csrf", csrfToken ctx)
|
||||
return! viewForTheme "admin" "user-edit" next ctx hash
|
||||
}
|
||||
|
||||
// GET /admin/user/edit
|
||||
let edit : HttpHandler = fun next ctx -> task {
|
||||
match! ctx.Data.WebLogUser.findById (userId ctx) ctx.WebLog.id with
|
||||
| Some user -> return! showEdit (Hash.FromAnonymousObject {| model = EditUserModel.fromUser user |}) next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/user/save
|
||||
let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
|
||||
let! model = ctx.BindFormAsync<EditUserModel> ()
|
||||
if model.newPassword = model.newPasswordConfirm then
|
||||
let data = ctx.Data
|
||||
match! data.WebLogUser.findById (userId ctx) ctx.WebLog.id with
|
||||
| Some user ->
|
||||
let pw, salt =
|
||||
if model.newPassword = "" then
|
||||
user.passwordHash, user.salt
|
||||
else
|
||||
let newSalt = Guid.NewGuid ()
|
||||
hashedPassword model.newPassword user.userName newSalt, newSalt
|
||||
let user =
|
||||
{ user with
|
||||
firstName = model.firstName
|
||||
lastName = model.lastName
|
||||
preferredName = model.preferredName
|
||||
passwordHash = pw
|
||||
salt = salt
|
||||
}
|
||||
do! data.WebLogUser.update user
|
||||
let pwMsg = if model.newPassword = "" then "" else " and updated your password"
|
||||
do! addMessage ctx { UserMessage.success with message = $"Saved your information{pwMsg} successfully" }
|
||||
return! redirectToGet (WebLog.relativeUrl ctx.WebLog (Permalink "admin/user/edit")) next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
else
|
||||
do! addMessage ctx { UserMessage.error with message = "Passwords did not match; no updates made" }
|
||||
return! showEdit (Hash.FromAnonymousObject {|
|
||||
model = { model with newPassword = ""; newPasswordConfirm = "" }
|
||||
|}) next ctx
|
||||
}
|
395
src/MyWebLog/Maintenance.fs
Normal file
395
src/MyWebLog/Maintenance.fs
Normal file
|
@ -0,0 +1,395 @@
|
|||
module MyWebLog.Maintenance
|
||||
|
||||
open System
|
||||
open System.IO
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
open MyWebLog.Data
|
||||
|
||||
/// Create the web log information
|
||||
let private doCreateWebLog (args : string[]) (sp : IServiceProvider) = task {
|
||||
|
||||
let data = sp.GetRequiredService<IData> ()
|
||||
|
||||
let timeZone =
|
||||
let local = TimeZoneInfo.Local.Id
|
||||
match TimeZoneInfo.Local.HasIanaId with
|
||||
| true -> local
|
||||
| false ->
|
||||
match TimeZoneInfo.TryConvertWindowsIdToIanaId local with
|
||||
| true, ianaId -> ianaId
|
||||
| false, _ -> raise <| TimeZoneNotFoundException $"Cannot find IANA timezone for {local}"
|
||||
|
||||
// Create the web log
|
||||
let webLogId = WebLogId.create ()
|
||||
let userId = WebLogUserId.create ()
|
||||
let homePageId = PageId.create ()
|
||||
|
||||
do! data.WebLog.add
|
||||
{ WebLog.empty with
|
||||
id = webLogId
|
||||
name = args[2]
|
||||
urlBase = args[1]
|
||||
defaultPage = PageId.toString homePageId
|
||||
timeZone = timeZone
|
||||
}
|
||||
|
||||
// Create the admin user
|
||||
let salt = Guid.NewGuid ()
|
||||
|
||||
do! data.WebLogUser.add
|
||||
{ WebLogUser.empty with
|
||||
id = userId
|
||||
webLogId = webLogId
|
||||
userName = args[3]
|
||||
firstName = "Admin"
|
||||
lastName = "User"
|
||||
preferredName = "Admin"
|
||||
passwordHash = Handlers.User.hashedPassword args[4] args[3] salt
|
||||
salt = salt
|
||||
authorizationLevel = Administrator
|
||||
}
|
||||
|
||||
// Create the default home page
|
||||
do! data.Page.add
|
||||
{ Page.empty with
|
||||
id = homePageId
|
||||
webLogId = webLogId
|
||||
authorId = userId
|
||||
title = "Welcome to myWebLog!"
|
||||
permalink = Permalink "welcome-to-myweblog.html"
|
||||
publishedOn = DateTime.UtcNow
|
||||
updatedOn = DateTime.UtcNow
|
||||
text = "<p>This is your default home page.</p>"
|
||||
revisions = [
|
||||
{ asOf = DateTime.UtcNow
|
||||
text = Html "<p>This is your default home page.</p>"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
printfn $"Successfully initialized database for {args[2]} with URL base {args[1]}"
|
||||
}
|
||||
|
||||
/// Create a new web log
|
||||
let createWebLog args sp = task {
|
||||
match args |> Array.length with
|
||||
| 5 -> do! doCreateWebLog args sp
|
||||
| _ -> printfn "Usage: MyWebLog init [url] [name] [admin-email] [admin-pw]"
|
||||
}
|
||||
|
||||
/// Import prior permalinks from a text files with lines in the format "[old] [new]"
|
||||
let importPriorPermalinks urlBase file (sp : IServiceProvider) = task {
|
||||
let data = sp.GetRequiredService<IData> ()
|
||||
|
||||
match! data.WebLog.findByHost urlBase with
|
||||
| Some webLog ->
|
||||
|
||||
let mapping =
|
||||
File.ReadAllLines file
|
||||
|> Seq.ofArray
|
||||
|> Seq.map (fun it ->
|
||||
let parts = it.Split " "
|
||||
Permalink parts[0], Permalink parts[1])
|
||||
|
||||
for old, current in mapping do
|
||||
match! data.Post.findByPermalink current webLog.id with
|
||||
| Some post ->
|
||||
let! withLinks = data.Post.findFullById post.id post.webLogId
|
||||
let! _ = data.Post.updatePriorPermalinks post.id post.webLogId
|
||||
(old :: withLinks.Value.priorPermalinks)
|
||||
printfn $"{Permalink.toString old} -> {Permalink.toString current}"
|
||||
| None -> printfn $"Cannot find current post for {Permalink.toString current}"
|
||||
printfn "Done!"
|
||||
| None -> eprintfn $"No web log found at {urlBase}"
|
||||
}
|
||||
|
||||
/// Import permalinks if all is well
|
||||
let importLinks args sp = task {
|
||||
match args |> Array.length with
|
||||
| 3 -> do! importPriorPermalinks args[1] args[2] sp
|
||||
| _ -> printfn "Usage: MyWebLog import-links [url] [file-name]"
|
||||
}
|
||||
|
||||
/// Load a theme from the given ZIP file
|
||||
let loadTheme (args : string[]) (sp : IServiceProvider) = task {
|
||||
if args.Length > 1 then
|
||||
let fileName =
|
||||
match args[1].LastIndexOf Path.DirectorySeparatorChar with
|
||||
| -1 -> args[1]
|
||||
| it -> args[1][(it + 1)..]
|
||||
match Handlers.Admin.getThemeName fileName with
|
||||
| Ok themeName ->
|
||||
let data = sp.GetRequiredService<IData> ()
|
||||
let clean = if args.Length > 2 then bool.Parse args[2] else true
|
||||
use stream = File.Open (args[1], FileMode.Open)
|
||||
use copy = new MemoryStream ()
|
||||
do! stream.CopyToAsync copy
|
||||
do! Handlers.Admin.loadThemeFromZip themeName copy clean data
|
||||
printfn $"Theme {themeName} loaded successfully"
|
||||
| Error message -> eprintfn $"{message}"
|
||||
else
|
||||
printfn "Usage: MyWebLog load-theme [theme-zip-file-name] [*clean-load]"
|
||||
printfn " * optional, defaults to true"
|
||||
}
|
||||
|
||||
/// Back up a web log's data
|
||||
module Backup =
|
||||
|
||||
open System.Threading.Tasks
|
||||
open MyWebLog.Converters
|
||||
open Newtonsoft.Json
|
||||
|
||||
/// A theme asset, with the data base-64 encoded
|
||||
type EncodedAsset =
|
||||
{ /// The ID of the theme asset
|
||||
id : ThemeAssetId
|
||||
|
||||
/// The updated date for this asset
|
||||
updatedOn : DateTime
|
||||
|
||||
/// The data for this asset, base-64 encoded
|
||||
data : string
|
||||
}
|
||||
|
||||
/// Create an encoded theme asset from the original theme asset
|
||||
static member fromAsset (asset : ThemeAsset) =
|
||||
{ id = asset.id
|
||||
updatedOn = asset.updatedOn
|
||||
data = Convert.ToBase64String asset.data
|
||||
}
|
||||
|
||||
/// Create a theme asset from an encoded theme asset
|
||||
static member fromAsset (asset : EncodedAsset) : ThemeAsset =
|
||||
{ id = asset.id
|
||||
updatedOn = asset.updatedOn
|
||||
data = Convert.FromBase64String asset.data
|
||||
}
|
||||
|
||||
|
||||
/// A unified archive for a web log
|
||||
type Archive =
|
||||
{ /// The web log to which this archive belongs
|
||||
webLog : WebLog
|
||||
|
||||
/// The users for this web log
|
||||
users : WebLogUser list
|
||||
|
||||
/// The theme used by this web log at the time the archive was made
|
||||
theme : Theme
|
||||
|
||||
/// Assets for the theme used by this web log at the time the archive was made
|
||||
assets : EncodedAsset list
|
||||
|
||||
/// The categories for this web log
|
||||
categories : Category list
|
||||
|
||||
/// The tag mappings for this web log
|
||||
tagMappings : TagMap list
|
||||
|
||||
/// The pages for this web log (containing only the most recent revision)
|
||||
pages : Page list
|
||||
|
||||
/// The posts for this web log (containing only the most recent revision)
|
||||
posts : Post list
|
||||
}
|
||||
|
||||
/// Create a JSON serializer (uses RethinkDB data implementation's JSON converters)
|
||||
let private getSerializer prettyOutput =
|
||||
let serializer = JsonSerializer.CreateDefault ()
|
||||
Json.all () |> Seq.iter serializer.Converters.Add
|
||||
if prettyOutput then serializer.Formatting <- Formatting.Indented
|
||||
serializer
|
||||
|
||||
/// Display statistics for a backup archive
|
||||
let private displayStats (msg : string) (webLog : WebLog) archive =
|
||||
|
||||
let userCount = List.length archive.users
|
||||
let assetCount = List.length archive.assets
|
||||
let categoryCount = List.length archive.categories
|
||||
let tagMapCount = List.length archive.tagMappings
|
||||
let pageCount = List.length archive.pages
|
||||
let postCount = List.length archive.posts
|
||||
|
||||
// Create a pluralized output based on the count
|
||||
let plural count ifOne ifMany =
|
||||
if count = 1 then ifOne else ifMany
|
||||
|
||||
printfn ""
|
||||
printfn $"""{msg.Replace ("{{NAME}}", webLog.name)}"""
|
||||
printfn $""" - The theme "{archive.theme.name}" with {assetCount} asset{plural assetCount "" "s"}"""
|
||||
printfn $""" - {userCount} user{plural userCount "" "s"}"""
|
||||
printfn $""" - {categoryCount} categor{plural categoryCount "y" "ies"}"""
|
||||
printfn $""" - {tagMapCount} tag mapping{plural tagMapCount "" "s"}"""
|
||||
printfn $""" - {pageCount} page{plural pageCount "" "s"}"""
|
||||
printfn $""" - {postCount} post{plural postCount "" "s"}"""
|
||||
|
||||
/// Create a backup archive
|
||||
let private createBackup webLog (fileName : string) prettyOutput (data : IData) = task {
|
||||
// Create the data structure
|
||||
let themeId = ThemeId webLog.themePath
|
||||
|
||||
printfn "- Exporting theme..."
|
||||
let! theme = data.Theme.findById themeId
|
||||
let! assets = data.ThemeAsset.findByThemeWithData themeId
|
||||
|
||||
printfn "- Exporting users..."
|
||||
let! users = data.WebLogUser.findByWebLog webLog.id
|
||||
|
||||
printfn "- Exporting categories and tag mappings..."
|
||||
let! categories = data.Category.findByWebLog webLog.id
|
||||
let! tagMaps = data.TagMap.findByWebLog webLog.id
|
||||
|
||||
printfn "- Exporting pages..."
|
||||
let! pages = data.Page.findFullByWebLog webLog.id
|
||||
|
||||
printfn "- Exporting posts..."
|
||||
let! posts = data.Post.findFullByWebLog webLog.id
|
||||
|
||||
printfn "- Writing archive..."
|
||||
let archive = {
|
||||
webLog = webLog
|
||||
users = users
|
||||
theme = Option.get theme
|
||||
assets = assets |> List.map EncodedAsset.fromAsset
|
||||
categories = categories
|
||||
tagMappings = tagMaps
|
||||
pages = pages |> List.map (fun p -> { p with revisions = List.truncate 1 p.revisions })
|
||||
posts = posts |> List.map (fun p -> { p with revisions = List.truncate 1 p.revisions })
|
||||
}
|
||||
|
||||
// Write the structure to the backup file
|
||||
if File.Exists fileName then File.Delete fileName
|
||||
let serializer = getSerializer prettyOutput
|
||||
use writer = new StreamWriter (fileName)
|
||||
serializer.Serialize (writer, archive)
|
||||
writer.Close ()
|
||||
|
||||
displayStats "{{NAME}} backup contains:" webLog archive
|
||||
}
|
||||
|
||||
let private doRestore archive newUrlBase (data : IData) = task {
|
||||
let! restore = task {
|
||||
match! data.WebLog.findById archive.webLog.id with
|
||||
| Some webLog when defaultArg newUrlBase webLog.urlBase = webLog.urlBase ->
|
||||
do! data.WebLog.delete webLog.id
|
||||
return archive
|
||||
| Some _ ->
|
||||
// Err'body gets new IDs...
|
||||
let newWebLogId = WebLogId.create ()
|
||||
let newCatIds = archive.categories |> List.map (fun cat -> cat.id, CategoryId.create ()) |> dict
|
||||
let newMapIds = archive.tagMappings |> List.map (fun tm -> tm.id, TagMapId.create ()) |> dict
|
||||
let newPageIds = archive.pages |> List.map (fun page -> page.id, PageId.create ()) |> dict
|
||||
let newPostIds = archive.posts |> List.map (fun post -> post.id, PostId.create ()) |> dict
|
||||
let newUserIds = archive.users |> List.map (fun user -> user.id, WebLogUserId.create ()) |> dict
|
||||
return
|
||||
{ archive with
|
||||
webLog = { archive.webLog with id = newWebLogId; urlBase = Option.get newUrlBase }
|
||||
users = archive.users
|
||||
|> List.map (fun u -> { u with id = newUserIds[u.id]; webLogId = newWebLogId })
|
||||
categories = archive.categories
|
||||
|> List.map (fun c -> { c with id = newCatIds[c.id]; webLogId = newWebLogId })
|
||||
tagMappings = archive.tagMappings
|
||||
|> List.map (fun tm -> { tm with id = newMapIds[tm.id]; webLogId = newWebLogId })
|
||||
pages = archive.pages
|
||||
|> List.map (fun page ->
|
||||
{ page with
|
||||
id = newPageIds[page.id]
|
||||
webLogId = newWebLogId
|
||||
authorId = newUserIds[page.authorId]
|
||||
})
|
||||
posts = archive.posts
|
||||
|> List.map (fun post ->
|
||||
{ post with
|
||||
id = newPostIds[post.id]
|
||||
webLogId = newWebLogId
|
||||
authorId = newUserIds[post.authorId]
|
||||
categoryIds = post.categoryIds |> List.map (fun c -> newCatIds[c])
|
||||
})
|
||||
}
|
||||
| None ->
|
||||
return
|
||||
{ archive with
|
||||
webLog = { archive.webLog with urlBase = defaultArg newUrlBase archive.webLog.urlBase }
|
||||
}
|
||||
}
|
||||
|
||||
// Restore theme and assets (one at a time, as assets can be large)
|
||||
printfn ""
|
||||
printfn "- Importing theme..."
|
||||
do! data.Theme.save restore.theme
|
||||
let! _ = restore.assets |> List.map (EncodedAsset.fromAsset >> data.ThemeAsset.save) |> Task.WhenAll
|
||||
|
||||
// Restore web log data
|
||||
|
||||
printfn "- Restoring web log..."
|
||||
do! data.WebLog.add restore.webLog
|
||||
|
||||
printfn "- Restoring users..."
|
||||
do! data.WebLogUser.restore restore.users
|
||||
|
||||
printfn "- Restoring categories and tag mappings..."
|
||||
do! data.TagMap.restore restore.tagMappings
|
||||
do! data.Category.restore restore.categories
|
||||
|
||||
printfn "- Restoring pages..."
|
||||
do! data.Page.restore restore.pages
|
||||
|
||||
printfn "- Restoring posts..."
|
||||
do! data.Post.restore restore.posts
|
||||
|
||||
// TODO: comments not yet implemented
|
||||
|
||||
displayStats "Restored for {{NAME}}:" restore.webLog restore
|
||||
}
|
||||
|
||||
/// Decide whether to restore a backup
|
||||
let private restoreBackup (fileName : string) newUrlBase promptForOverwrite data = task {
|
||||
|
||||
let serializer = getSerializer false
|
||||
use stream = new FileStream (fileName, FileMode.Open)
|
||||
use reader = new StreamReader (stream)
|
||||
use jsonReader = new JsonTextReader (reader)
|
||||
let archive = serializer.Deserialize<Archive> jsonReader
|
||||
|
||||
let mutable doOverwrite = not promptForOverwrite
|
||||
if promptForOverwrite then
|
||||
printfn "** WARNING: Restoring a web log will delete existing data for that web log"
|
||||
printfn " (unless restoring to a different URL base), and will overwrite the"
|
||||
printfn " theme in either case."
|
||||
printfn ""
|
||||
printf "Continue? [Y/n] "
|
||||
doOverwrite <- not ((Console.ReadKey ()).Key = ConsoleKey.N)
|
||||
|
||||
if doOverwrite then
|
||||
do! doRestore archive newUrlBase data
|
||||
else
|
||||
printfn $"{archive.webLog.name} backup restoration canceled"
|
||||
}
|
||||
|
||||
/// Generate a backup archive
|
||||
let generateBackup (args : string[]) (sp : IServiceProvider) = task {
|
||||
if args.Length = 3 || args.Length = 4 then
|
||||
let data = sp.GetRequiredService<IData> ()
|
||||
match! data.WebLog.findByHost args[1] with
|
||||
| Some webLog ->
|
||||
let fileName = if args[2].EndsWith ".json" then args[2] else $"{args[2]}.json"
|
||||
let prettyOutput = args.Length = 4 && args[3] = "pretty"
|
||||
do! createBackup webLog fileName prettyOutput data
|
||||
| None -> printfn $"Error: no web log found for {args[1]}"
|
||||
else
|
||||
printfn """Usage: MyWebLog backup [url-base] [backup-file-name] [*"pretty"]"""
|
||||
printfn """ * optional - default is non-pretty JSON output"""
|
||||
}
|
||||
|
||||
/// Restore a backup archive
|
||||
let restoreFromBackup (args : string[]) (sp : IServiceProvider) = task {
|
||||
if args.Length = 2 || args.Length = 3 then
|
||||
let data = sp.GetRequiredService<IData> ()
|
||||
let newUrlBase = if args.Length = 3 then Some args[2] else None
|
||||
do! restoreBackup args[1] newUrlBase (args[0] <> "do-restore") data
|
||||
else
|
||||
printfn "Usage: MyWebLog restore [backup-file-name] [*url-base]"
|
||||
printfn " * optional - will restore to original URL base if omitted"
|
||||
printfn " (use do-restore to skip confirmation prompt)"
|
||||
}
|
||||
|
44
src/MyWebLog/MyWebLog.fsproj
Normal file
44
src/MyWebLog/MyWebLog.fsproj
Normal file
|
@ -0,0 +1,44 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<NoWarn>3391</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="appsettings.json" CopyToOutputDirectory="Always" />
|
||||
<Compile Include="Caches.fs" />
|
||||
<Compile Include="Handlers\Error.fs" />
|
||||
<Compile Include="Handlers\Helpers.fs" />
|
||||
<Compile Include="Handlers\Admin.fs" />
|
||||
<Compile Include="Handlers\Feed.fs" />
|
||||
<Compile Include="Handlers\Post.fs" />
|
||||
<Compile Include="Handlers\User.fs" />
|
||||
<Compile Include="Handlers\Routes.fs" />
|
||||
<Compile Include="DotLiquidBespoke.fs" />
|
||||
<Compile Include="Maintenance.fs" />
|
||||
<Compile Include="Program.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DotLiquid" Version="2.2.656" />
|
||||
<PackageReference Include="Giraffe" Version="6.0.0" />
|
||||
<PackageReference Include="Giraffe.Htmx" Version="1.7.0" />
|
||||
<PackageReference Include="Giraffe.ViewEngine.Htmx" Version="1.7.0" />
|
||||
<PackageReference Include="NeoSmart.Caching.Sqlite" Version="6.0.1" />
|
||||
<PackageReference Include="RethinkDB.DistributedCache" Version="1.0.0-rc1" />
|
||||
<PackageReference Update="FSharp.Core" Version="6.0.5" />
|
||||
<PackageReference Include="System.ServiceModel.Syndication" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MyWebLog.Data\MyWebLog.Data.fsproj" />
|
||||
<ProjectReference Include="..\MyWebLog.Domain\MyWebLog.Domain.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include=".\wwwroot\img\*.png" CopyToOutputDirectory="Always" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
|
@ -1,21 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="14.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">14.0</VisualStudioVersion>
|
||||
<VSToolsPath Condition="'$(VSToolsPath)' == ''">$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)</VSToolsPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.Props" Condition="'$(VSToolsPath)' != ''" />
|
||||
<PropertyGroup Label="Globals">
|
||||
<ProjectGuid>B9F6DB52-65A1-4C2A-8C97-739E08A1D4FB</ProjectGuid>
|
||||
<RootNamespace>MyWebLog</RootNamespace>
|
||||
<BaseIntermediateOutputPath Condition="'$(BaseIntermediateOutputPath)'=='' ">.\obj</BaseIntermediateOutputPath>
|
||||
<OutputPath Condition="'$(OutputPath)'=='' ">.\bin\</OutputPath>
|
||||
<TargetFrameworkVersion>v4.5.2</TargetFrameworkVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<SchemaVersion>2.0</SchemaVersion>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.targets" Condition="'$(VSToolsPath)' != ''" />
|
||||
</Project>
|
|
@ -1,10 +0,0 @@
|
|||
namespace MyWebLog
|
||||
{
|
||||
class Program
|
||||
{
|
||||
static void Main(string[] args)
|
||||
{
|
||||
App.Run();
|
||||
}
|
||||
}
|
||||
}
|
147
src/MyWebLog/Program.fs
Normal file
147
src/MyWebLog/Program.fs
Normal file
|
@ -0,0 +1,147 @@
|
|||
open Microsoft.AspNetCore.Http
|
||||
open Microsoft.Data.Sqlite
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Microsoft.Extensions.Logging
|
||||
open MyWebLog
|
||||
|
||||
/// Middleware to derive the current web log
|
||||
type WebLogMiddleware (next : RequestDelegate, log : ILogger<WebLogMiddleware>) =
|
||||
|
||||
/// Is the debug level enabled on the logger?
|
||||
let isDebug = log.IsEnabled LogLevel.Debug
|
||||
|
||||
member this.InvokeAsync (ctx : HttpContext) = task {
|
||||
/// Create the full path of the request
|
||||
let path = $"{ctx.Request.Scheme}://{ctx.Request.Host.Value}{ctx.Request.Path.Value}"
|
||||
match WebLogCache.tryGet path with
|
||||
| Some webLog ->
|
||||
if isDebug then log.LogDebug $"Resolved web log {WebLogId.toString webLog.id} for {path}"
|
||||
ctx.Items["webLog"] <- webLog
|
||||
if PageListCache.exists ctx then () else do! PageListCache.update ctx
|
||||
if CategoryCache.exists ctx then () else do! CategoryCache.update ctx
|
||||
return! next.Invoke ctx
|
||||
| None ->
|
||||
if isDebug then log.LogDebug $"No resolved web log for {path}"
|
||||
ctx.Response.StatusCode <- 404
|
||||
}
|
||||
|
||||
|
||||
open System
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
open MyWebLog.Data
|
||||
|
||||
/// Logic to obtain a data connection and implementation based on configured values
|
||||
module DataImplementation =
|
||||
|
||||
open MyWebLog.Converters
|
||||
open RethinkDb.Driver.FSharp
|
||||
open RethinkDb.Driver.Net
|
||||
|
||||
/// Get the configured data implementation
|
||||
let get (sp : IServiceProvider) : IData =
|
||||
let config = sp.GetRequiredService<IConfiguration> ()
|
||||
if (config.GetConnectionString >> isNull >> not) "SQLite" then
|
||||
let conn = new SqliteConnection (config.GetConnectionString "SQLite")
|
||||
SQLiteData.setUpConnection conn |> Async.AwaitTask |> Async.RunSynchronously
|
||||
upcast SQLiteData (conn, sp.GetRequiredService<ILogger<SQLiteData>> ())
|
||||
elif (config.GetSection "RethinkDB").Exists () then
|
||||
Json.all () |> Seq.iter Converter.Serializer.Converters.Add
|
||||
let rethinkCfg = DataConfig.FromConfiguration (config.GetSection "RethinkDB")
|
||||
let conn = rethinkCfg.CreateConnectionAsync () |> Async.AwaitTask |> Async.RunSynchronously
|
||||
upcast RethinkDbData (conn, rethinkCfg, sp.GetRequiredService<ILogger<RethinkDbData>> ())
|
||||
else
|
||||
let log = sp.GetRequiredService<ILogger<SQLiteData>> ()
|
||||
log.LogInformation "Using default SQLite database myweblog.db"
|
||||
let conn = new SqliteConnection ("Data Source=./myweblog.db;Cache=Shared")
|
||||
SQLiteData.setUpConnection conn |> Async.AwaitTask |> Async.RunSynchronously
|
||||
upcast SQLiteData (conn, log)
|
||||
|
||||
|
||||
open Giraffe
|
||||
open Giraffe.EndpointRouting
|
||||
open Microsoft.AspNetCore.Authentication.Cookies
|
||||
open Microsoft.AspNetCore.Builder
|
||||
open Microsoft.AspNetCore.HttpOverrides
|
||||
open NeoSmart.Caching.Sqlite
|
||||
open RethinkDB.DistributedCache
|
||||
|
||||
[<EntryPoint>]
|
||||
let rec main args =
|
||||
|
||||
let builder = WebApplication.CreateBuilder(args)
|
||||
let _ = builder.Services.Configure<ForwardedHeadersOptions>(fun (opts : ForwardedHeadersOptions) ->
|
||||
opts.ForwardedHeaders <- ForwardedHeaders.XForwardedFor ||| ForwardedHeaders.XForwardedProto)
|
||||
let _ =
|
||||
builder.Services
|
||||
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(fun opts ->
|
||||
opts.ExpireTimeSpan <- TimeSpan.FromMinutes 60.
|
||||
opts.SlidingExpiration <- true
|
||||
opts.AccessDeniedPath <- "/forbidden")
|
||||
let _ = builder.Services.AddLogging ()
|
||||
let _ = builder.Services.AddAuthorization ()
|
||||
let _ = builder.Services.AddAntiforgery ()
|
||||
|
||||
let sp = builder.Services.BuildServiceProvider ()
|
||||
let data = DataImplementation.get sp
|
||||
|
||||
task {
|
||||
do! data.startUp ()
|
||||
do! WebLogCache.fill data
|
||||
do! ThemeAssetCache.fill data
|
||||
} |> Async.AwaitTask |> Async.RunSynchronously
|
||||
|
||||
// Define distributed cache implementation based on data implementation
|
||||
match data with
|
||||
| :? RethinkDbData as rethink ->
|
||||
// A RethinkDB connection is designed to work as a singleton
|
||||
builder.Services.AddSingleton<IData> data |> ignore
|
||||
builder.Services.AddDistributedRethinkDBCache (fun opts ->
|
||||
opts.TableName <- "Session"
|
||||
opts.Connection <- rethink.Conn)
|
||||
|> ignore
|
||||
| :? SQLiteData as sql ->
|
||||
// ADO.NET connections are designed to work as per-request instantiation
|
||||
let cfg = sp.GetRequiredService<IConfiguration> ()
|
||||
builder.Services.AddScoped<SqliteConnection> (fun sp ->
|
||||
let conn = new SqliteConnection (sql.Conn.ConnectionString)
|
||||
SQLiteData.setUpConnection conn |> Async.AwaitTask |> Async.RunSynchronously
|
||||
conn)
|
||||
|> ignore
|
||||
builder.Services.AddScoped<IData, SQLiteData> () |> ignore
|
||||
// Use SQLite for caching as well
|
||||
let cachePath = Option.ofObj (cfg.GetConnectionString "SQLiteCachePath") |> Option.defaultValue "./session.db"
|
||||
builder.Services.AddSqliteCache (fun o -> o.CachePath <- cachePath) |> ignore
|
||||
| _ -> ()
|
||||
|
||||
let _ = builder.Services.AddSession(fun opts ->
|
||||
opts.IdleTimeout <- TimeSpan.FromMinutes 60
|
||||
opts.Cookie.HttpOnly <- true
|
||||
opts.Cookie.IsEssential <- true)
|
||||
let _ = builder.Services.AddGiraffe ()
|
||||
|
||||
// Set up DotLiquid
|
||||
DotLiquidBespoke.register ()
|
||||
|
||||
let app = builder.Build ()
|
||||
|
||||
match args |> Array.tryHead with
|
||||
| Some it when it = "init" -> Maintenance.createWebLog args app.Services
|
||||
| Some it when it = "import-links" -> Maintenance.importLinks args app.Services
|
||||
| Some it when it = "load-theme" -> Maintenance.loadTheme args app.Services
|
||||
| Some it when it = "backup" -> Maintenance.Backup.generateBackup args app.Services
|
||||
| Some it when it = "restore" -> Maintenance.Backup.restoreFromBackup args app.Services
|
||||
| _ ->
|
||||
let _ = app.UseForwardedHeaders ()
|
||||
let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict))
|
||||
let _ = app.UseMiddleware<WebLogMiddleware> ()
|
||||
let _ = app.UseAuthentication ()
|
||||
let _ = app.UseStaticFiles ()
|
||||
let _ = app.UseRouting ()
|
||||
let _ = app.UseSession ()
|
||||
let _ = app.UseGiraffe Handlers.Routes.endpoint
|
||||
|
||||
System.Threading.Tasks.Task.FromResult (app.Run ())
|
||||
|> Async.AwaitTask |> Async.RunSynchronously
|
||||
|
||||
0 // Exit code
|
|
@ -1,15 +0,0 @@
|
|||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
[assembly: AssemblyTitle("MyWebLog")]
|
||||
[assembly: AssemblyDescription("A lightweight blogging platform built on Nancy, and RethinkDB")]
|
||||
[assembly: AssemblyConfiguration("")]
|
||||
[assembly: AssemblyCompany("")]
|
||||
[assembly: AssemblyProduct("MyWebLog")]
|
||||
[assembly: AssemblyCopyright("Copyright © 2016")]
|
||||
[assembly: AssemblyTrademark("")]
|
||||
[assembly: AssemblyCulture("")]
|
||||
[assembly: ComVisible(false)]
|
||||
[assembly: Guid("b9f6db52-65a1-4c2a-8c97-739e08a1d4fb")]
|
||||
[assembly: AssemblyVersion("0.9.2.0")]
|
||||
[assembly: AssemblyFileVersion("1.0.0.0")]
|
12
src/MyWebLog/appsettings.json
Normal file
12
src/MyWebLog/appsettings.json
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"RethinkDB": {
|
||||
"hostname": "data02.bitbadger.solutions",
|
||||
"database": "myWebLog_dev"
|
||||
},
|
||||
"Generator": "myWebLog 2.0-alpha36",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"MyWebLog.Handlers": "Debug"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
// https://www.grc.com/passwords.htm is a great source of high-entropy passwords for these first 4 settings.
|
||||
// Although what is there looks strong, keep in mind that it's what's in source control, so all instances of myWebLog
|
||||
// could be using these values; that severly decreases their usefulness. :)
|
||||
//
|
||||
// WARNING: Changing this first one will render every single user's login inaccessible, including yours. Only do
|
||||
// this if you are editing this file before setting up an instance, or if that is what you intend to do.
|
||||
"password-salt": "3RVkw1jESpLFHr8F3WTThSbFnO3tFrMIckQsKzc9dymzEEXUoUS7nurF4rGpJ8Z",
|
||||
// Changing any of these next 3 will render all current logins invalid, and the user will be force to reauthenticate.
|
||||
"auth-salt": "2TweL5wcyGWg5CmMqZSZMonbe9xqQ2Q4vDNeysFRaUgVs4BpFZL85Iew79tn2IJ",
|
||||
"encryption-passphrase": "jZjY6XyqUZypBcT0NaDXjEKc8xUjB4eb4V9EDHDedadRLuRUeRvIQx67yhx6bQP",
|
||||
"hmac-passphrase": "42dzKb93X8YUkK8ms8JldjtkEvCKnPQGWCkO2yFaZ7lkNwECGCX00xzrx5ZSElO",
|
||||
"data": {
|
||||
"database": "myWebLog",
|
||||
"hostname": "localhost"
|
||||
}
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
{
|
||||
"buildOptions": {
|
||||
"emitEntryPoint": true,
|
||||
"copyToOutput": {
|
||||
"include": [ "views", "content", "config.json" ]
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"MyWebLog.App": "0.9.2",
|
||||
},
|
||||
"frameworks": {
|
||||
"netcoreapp1.0": {
|
||||
"dependencies": {
|
||||
"Microsoft.NETCore.App": {
|
||||
"type": "platform",
|
||||
"version": "1.1.0"
|
||||
}
|
||||
},
|
||||
"imports": "dnxcore50"
|
||||
}
|
||||
},
|
||||
"publishOptions": {
|
||||
"include": [ "views", "content", "config.json" ]
|
||||
},
|
||||
"version": "0.9.2"
|
||||
}
|
|
@ -1,52 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<title>@Model.PageTitle | @Translate.Admin | @Model.WebLog.Name</title>
|
||||
<link rel="stylesheet" type="text/css" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.4/css/bootstrap.css" />
|
||||
<link rel="stylesheet" type="text/css" href="//maxcdn.bootstrapcdn.com/bootswatch/3.3.4/cosmo/bootstrap.min.css" />
|
||||
<link rel="stylesheet" type="text/css" href="//maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css" />
|
||||
<link rel="stylesheet" type="text/css" href="/admin/content/admin.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav class="navbar navbar-inverse">
|
||||
<div class="container-fluid">
|
||||
<div class="navbar-header">
|
||||
<a class="navbar-brand" href="/">@Model.WebLog.Name</a>
|
||||
</div>
|
||||
<div class="navbar-left">
|
||||
<p class="navbar-text">@Model.PageTitle</p>
|
||||
</div>
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
@If.IsAuthenticated
|
||||
<li><a href="/admin">@Translate.Dashboard</a></li>
|
||||
<li><a href="/user/log-off">@Translate.LogOff</a></li>
|
||||
@EndIf
|
||||
@IfNot.IsAuthenticated
|
||||
<li><a href="/user/log-on">@Translate.LogOn</a></li>
|
||||
@EndIf
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="container">
|
||||
@Each.Messages
|
||||
@Current.ToDisplay
|
||||
@EndEach
|
||||
@Section['Content'];
|
||||
</div>
|
||||
<footer>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-xs-12 text-right">@Model.FooterLogoLight </div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script type="text/javascript" src="//ajax.aspnetcdn.com/ajax/jQuery/jquery-2.1.3.min.js"></script>
|
||||
<script type="text/javascript" src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.4/js/bootstrap.min.js"></script>
|
||||
<script type="text/javascript" src="//cdn.tinymce.com/4/tinymce.min.js"></script>
|
||||
@Section['Scripts'];
|
||||
</body>
|
||||
</html>
|
|
@ -1,55 +0,0 @@
|
|||
@Master['admin/admin-layout']
|
||||
|
||||
@Section['Content']
|
||||
<form action="/category/@Model.Category.Id/edit" method="post">
|
||||
@AntiForgeryToken
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<a href="/categories" class="btn btn-default">
|
||||
<i class="fa fa-list-ul"></i> @Translate.BackToCategoryList
|
||||
</a>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="Name">@Translate.Name</label>
|
||||
<input type="text" class="form-control" id="Name" name="Name" value="@Model.Form.Name" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-8">
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="Slug">@Translate.Slug</label>
|
||||
<input type="text" class="form-control" id="Slug" name="Slug" value="@Model.Form.Slug" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="Description">@Translate.Description</label>
|
||||
<textarea class="form-control" rows="4" id="Description" name="Description">@Model.Form.Description</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-4">
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="ParentId">@Translate.ParentCategory</label>
|
||||
<select class="form-control" id="ParentId" name="ParentId">
|
||||
<option value="">— @Translate.NoParent —</option>
|
||||
@Each.Categories
|
||||
@Current.Option
|
||||
@EndEach
|
||||
</select>
|
||||
</div>
|
||||
<br />
|
||||
<p class="text-center">
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<i class="fa fa-floppy-o"></i> @Translate.Save
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@EndSection
|
||||
|
||||
@Section['Scripts']
|
||||
<script type="text/javascript">
|
||||
/* <![CDATA[ */
|
||||
$(document).ready(function () { $("#Name").focus() })
|
||||
/* ]] */
|
||||
</script>
|
||||
@EndSection
|
|
@ -1,51 +0,0 @@
|
|||
@Master['admin/admin-layout']
|
||||
|
||||
@Section['Content']
|
||||
<div class="row">
|
||||
<p><a class="btn btn-primary" href="/category/new/edit"><i class="fa fa-plus"></i> @Translate.AddNew</a></p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<table class="table table-hover">
|
||||
<tr>
|
||||
<th>@Translate.Action</th>
|
||||
<th>@Translate.Category</th>
|
||||
<th>@Translate.Description</th>
|
||||
</tr>
|
||||
@Each.Categories
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/category/@Current.Category.Id/edit">@Translate.Edit</a>
|
||||
<a href="javascript:void(0)" onclick="deleteCategory('@Current.Category.Id', '@Current.Category.Name')">
|
||||
@Translate.Delete
|
||||
</a>
|
||||
</td>
|
||||
<td>@Current.ListName</td>
|
||||
<td>
|
||||
@If.HasDescription
|
||||
@Current.Category.Description.Value
|
||||
@EndIf
|
||||
@IfNot.HasDescription
|
||||
|
||||
@EndIf
|
||||
</td>
|
||||
</tr>
|
||||
@EndEach
|
||||
</table>
|
||||
</div>
|
||||
<form method="post" id="deleteForm">
|
||||
@AntiForgeryToken
|
||||
</form>
|
||||
@EndSection
|
||||
|
||||
@Section['Scripts']
|
||||
<script type="text/javascript">
|
||||
/* <![CDATA[ */
|
||||
function deleteCategory(id, title) {
|
||||
if (confirm('@Translate.CategoryDeleteWarning "' + title + '"?')) {
|
||||
document.getElementById("deleteForm").action = "/category/" + id + "/delete"
|
||||
document.getElementById("deleteForm").submit()
|
||||
}
|
||||
}
|
||||
/* ]] */
|
||||
</script>
|
||||
@EndSection
|
|
@ -1,5 +0,0 @@
|
|||
footer {
|
||||
background-color: #808080;
|
||||
border-top: solid 1px black;
|
||||
color: white;
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
tinymce.init({
|
||||
menubar: false,
|
||||
plugins: [
|
||||
"advlist autolink link image lists charmap print preview hr anchor pagebreak spellchecker",
|
||||
"searchreplace wordcount visualblocks visualchars code fullscreen insertdatetime media nonbreaking",
|
||||
"save table contextmenu directionality emoticons template paste textcolor"
|
||||
],
|
||||
selector: "textarea",
|
||||
toolbar: "styleselect | forecolor backcolor | bullist numlist | link unlink anchor | paste pastetext | spellchecker | visualblocks visualchars | code fullscreen"
|
||||
})
|
|
@ -1,31 +0,0 @@
|
|||
@Master['admin/admin-layout']
|
||||
|
||||
@Section['Content']
|
||||
<div class="row">
|
||||
<div class="col-xs-4 text-center">
|
||||
<h3>@Translate.Posts <span class="badge">@Model.Posts</span></h3>
|
||||
<p>
|
||||
<a href="/posts/list"><i class="fa fa-list-ul"></i> @Translate.ListAll</a>
|
||||
|
||||
<a href="/post/new/edit"><i class="fa fa-plus"></i> @Translate.AddNew</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-xs-4 text-center">
|
||||
<h3>@Translate.Pages <span class="badge">@Model.Pages</span></h3>
|
||||
<p>
|
||||
<a href="/pages"><i class="fa fa-list-ul"></i> @Translate.ListAll</a>
|
||||
|
||||
<a href="/page/new/edit"><i class="fa fa-plus"></i> @Translate.AddNew</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-xs-4 text-center">
|
||||
<h3>@Translate.Categories <span class="badge">@Model.Categories</span></h3>
|
||||
<p>
|
||||
<a href="/categories"><i class="fa fa-list-ul"></i> @Translate.ListAll</a>
|
||||
|
||||
<a href="/category/new/edit"><i class="fa fa-plus"></i> @Translate.AddNew</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
@EndSection
|
|
@ -1,61 +0,0 @@
|
|||
@Master['admin/admin-layout']
|
||||
|
||||
@Section['Content']
|
||||
<form action="/page/@Model.Page.Id/edit" method="post">
|
||||
@AntiForgeryToken
|
||||
<div class="row">
|
||||
<div class="col-sm-9">
|
||||
<a href="/pages" class="btn btn-default">
|
||||
<i class="fa fa-list-ul"></i> @Translate.BackToPageList
|
||||
</a>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="Title">@Translate.Title</label>
|
||||
<input type="text" name="Title" id="Title" class="form-control" value="@Model.Form.Title" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="Permalink">@Translate.Permalink</label>
|
||||
<input type="text" name="Permalink" id="Permalink" class="form-control" value="@Model.Form.Permalink" />
|
||||
<p class="form-hint"><em>@Translate.startingWith</em> //@Model.WebLog.UrlBase/</p>
|
||||
</div>
|
||||
<!-- // TODO: Markdown / HTML choice -->
|
||||
<input type="hidden" name="Source" value="html" />
|
||||
<div class="form-group">
|
||||
<textarea name="Text" id="Text" rows="15" class="form-control">@Model.Form.Text</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">@Translate.PageDetails</div>
|
||||
<div class="panel-body">
|
||||
@IfNot.isNew
|
||||
<div class="form-group">
|
||||
<label class="control-label">@Translate.PublishedDate</label>
|
||||
<p class="static-control">@Model.PublishedDate<br />@Model.PublishedTime</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label">@Translate.LastUpdatedDate</label>
|
||||
<p class="static-control">@Model.LastUpdatedDate<br />@Model.LastUpdatedTime</p>
|
||||
</div>
|
||||
@EndIf
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="ShowInPageList" id="ShowInPageList" value="true" @Model.PageListChecked />
|
||||
<label for="ShowInPageList">@Translate.ShowInPageList</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p><button class="btn btn-primary" type="submit"><i class="fa fa-floppy-o"></i> @Translate.Save</button></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@EndSection
|
||||
|
||||
@Section['Scripts']
|
||||
<script type="text/javascript" src="/admin/content/tinymce-init.js"></script>
|
||||
<script type="text/javascript">
|
||||
/* <![CDATA[ */
|
||||
$(document).ready(function () { $("#Title").focus() })
|
||||
/* ]]> */
|
||||
</script>
|
||||
@EndSection
|
|
@ -1,42 +0,0 @@
|
|||
@Master['admin/admin-layout']
|
||||
|
||||
@Section['Content']
|
||||
<div class="row">
|
||||
<p><a class="btn btn-primary" href="/page/new/edit"><i class="fa fa-plus"></i> @Translate.AddNew</a></p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<table class="table table-hover">
|
||||
<tr>
|
||||
<th>@Translate.Title</th>
|
||||
<th>@Translate.LastUpdated</th>
|
||||
</tr>
|
||||
@Each.Pages
|
||||
<tr>
|
||||
<td>
|
||||
@Current.Page.Title<br />
|
||||
<a href="/@Current.Page.Permalink">@Translate.View</a>
|
||||
<a href="/page/@Current.Page.Id/edit">@Translate.Edit</a>
|
||||
<a href="javascript:void(0)" onclick="deletePage('@Current.Page.Id', '@Current.Page.Title')">@Translate.Delete</a>
|
||||
</td>
|
||||
<td>@Current.UpdatedDate<br />@Translate.at @Current.UpdatedTime</td>
|
||||
</tr>
|
||||
@EndEach
|
||||
</table>
|
||||
</div>
|
||||
<form method="delete" id="deleteForm">
|
||||
@AntiForgeryToken
|
||||
</form>
|
||||
@EndSection
|
||||
|
||||
@Section['Scripts']
|
||||
<script type="text/javascript">
|
||||
/* <![CDATA[ */
|
||||
function deletePage(id, title) {
|
||||
if (confirm('@Translate.PageDeleteWarning "' + title + '"?')) {
|
||||
document.getElementById("deleteForm").action = "/page/" + id + "/delete"
|
||||
document.getElementById("deleteForm").submit()
|
||||
}
|
||||
}
|
||||
/* ]] */
|
||||
</script>
|
||||
@EndSection
|
|
@ -1,90 +0,0 @@
|
|||
@Master['admin/admin-layout']
|
||||
|
||||
@Section['Content']
|
||||
<form action='/post/@Model.Post.Id/edit' method="post">
|
||||
@AntiForgeryToken
|
||||
<div class="row">
|
||||
<div class="col-sm-9">
|
||||
<a href="/posts/list" class="btn btn-default">
|
||||
<i class="fa fa-list-ul"></i> @Translate.BackToPostList
|
||||
</a>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="Title">@Translate.Title</label>
|
||||
<input type="text" name="Title" id="Title" class="form-control" value="@Model.Form.Title" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="Permalink">@Translate.Permalink</label>
|
||||
<input type="text" name="Permalink" id="Permalink" class="form-control" value="@Model.Form.Permalink" />
|
||||
<p class="form-hint"><em>@Translate.startingWith</em> //@Model.WebLog.UrlBase/ </p>
|
||||
</div>
|
||||
<!-- // TODO: Markdown / HTML choice -->
|
||||
<input type="hidden" name="Source" value="html" />
|
||||
<div class="form-group">
|
||||
<textarea name="Text" id="Text" rows="15">@Model.Form.Text</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="Tags">@Translate.Tags</label>
|
||||
<input type="text" name="Tags" id="Tags" class="form-control" value="@Model.Form.Tags" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">@Translate.PostDetails</h4>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="form-group">
|
||||
<label class="control-label">@Translate.PostStatus</label>
|
||||
<p class="static-control">@Model.Post.Status</p>
|
||||
</div>
|
||||
@If.IsPublished
|
||||
<div class="form-group">
|
||||
<label class="control-label">@Translate.PublishedDate</label>
|
||||
<p class="static-control">@Model.PublishedDate<br />@Model.PublishedTime</p>
|
||||
</div>
|
||||
@EndIf
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">@Translate.Categories</h4>
|
||||
</div>
|
||||
<div class="panel-body" style="max-height:350px;overflow:scroll;">
|
||||
@Each.Categories
|
||||
@Current.Indent
|
||||
<input type="checkbox" id="Category-@Current.Id" name="Categories" value="@Current.Id" @Current.CheckedAttr />
|
||||
|
||||
<label for="Category-@Current.Id" title="@Current.Description">@Current.Name</label>
|
||||
<br/>
|
||||
@EndEach
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
@If.IsPublished
|
||||
<input type="hidden" name="PublishNow" value="true" />
|
||||
@EndIf
|
||||
@IfNot.IsPublished
|
||||
<div>
|
||||
<input type="checkbox" name="PublishNow" id="PublishNow" value="true" @Model.PublishNowCheckedAttr />
|
||||
<label for="PublishNow">@Translate.PublishThisPost</label>
|
||||
</div>
|
||||
@EndIf
|
||||
<p>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fa fa-floppy-o"></i> @Translate.Save
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@EndSection
|
||||
|
||||
@Section['Scripts']
|
||||
<script type="text/javascript" src="/admin/content/tinymce-init.js"></script>
|
||||
<script type="text/javascript">
|
||||
/** <![CDATA[ */
|
||||
$(document).ready(function () { $("#Title").focus() })
|
||||
/** ]]> */
|
||||
</script>
|
||||
@EndSection
|
|
@ -1,49 +0,0 @@
|
|||
@Master['admin/admin-layout']
|
||||
|
||||
@Section['Content']
|
||||
<div class="row">
|
||||
<p>
|
||||
<a class="btn btn-primary" href="/post/new/edit">
|
||||
<i class="fa fa-plus"></i> @Translate.AddNew
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<table class="table table-hover">
|
||||
<tr>
|
||||
<th>@Translate.Date</th>
|
||||
<th>@Translate.Title</th>
|
||||
<th>@Translate.Status</th>
|
||||
<th>@Translate.Tags</th>
|
||||
</tr>
|
||||
@Each.Posts
|
||||
<tr>
|
||||
<td style="white-space:nowrap;">
|
||||
@Current.PublishedDate<br />
|
||||
@Translate.at @Current.PublishedTime
|
||||
</td>
|
||||
<td>
|
||||
@Current.Post.Title<br />
|
||||
<a href="/@Current.Post.Permalink">@Translate.View</a> |
|
||||
<a href="/post/@Current.Post.Id/edit">@Translate.Edit</a> |
|
||||
<a href="/post/@Current.Post.Id/delete">@Translate.Delete</a>
|
||||
</td>
|
||||
<td>@Current.Post.Status</td>
|
||||
<td>@Current.Tags</td>
|
||||
</tr>
|
||||
@EndEach
|
||||
</table>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-3 col-xs-offset-2">
|
||||
@If.HasNewer
|
||||
<p><a class="btn btn-default" href="@Model.NewerLink">« @Translate.NewerPosts</a></p>
|
||||
@EndIf
|
||||
</div>
|
||||
<div class="col-xs-3 col-xs-offset-1 text-right">
|
||||
@If.HasOlder
|
||||
<p><a class="btn btn-default" href="@Model.OlderLink">@Translate.OlderPosts »</a></p>
|
||||
@EndIf
|
||||
</div>
|
||||
</div>
|
||||
@EndSection
|
|
@ -1,41 +0,0 @@
|
|||
@Master['admin/admin-layout']
|
||||
|
||||
@Section['Content']
|
||||
<form action="/user/log-on" method="post">
|
||||
@AntiForgeryToken
|
||||
<input type="hidden" name="ReturnUrl" value="@Model.Form.ReturnUrl" />
|
||||
<div class="row">
|
||||
<div class="col-sm-offset-1 col-sm-8 col-md-offset-3 col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon" title="@Translate.EmailAddress"><i class="fa fa-envelope"></i></span>
|
||||
<input type="text" name="Email" id="Email" class="form-control" placeholder="@Translate.EmailAddress" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-offset-1 col-sm-8 col-md-offset-3 col-md-6">
|
||||
<br />
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon" title="@Translate.Password"><i class="fa fa-key"></i></span>
|
||||
<input type="password" name="Password" class="form-control" placeholder="@Translate.Password" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12 text-center">
|
||||
<p>
|
||||
<br />
|
||||
<button class="btn btn-primary"><i class="fa fa-sign-in"></i> @Translate.LogOn</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@EndSection
|
||||
|
||||
@Section['Scripts']
|
||||
<script type="text/javascript">
|
||||
/* <![CDATA[ */
|
||||
$(document).ready(function () { $("#Email").focus() })
|
||||
/* ]]> */
|
||||
</script>
|
||||
@EndSection
|
|
@ -1,4 +0,0 @@
|
|||
<h4>
|
||||
@Model.Commentor <small>@Model.CommentedOn</small>
|
||||
</h4>
|
||||
@Model.Comment.Text
|
|
@ -1,476 +0,0 @@
|
|||
/*!
|
||||
* Bootstrap v3.3.4 (http://getbootstrap.com)
|
||||
* Copyright 2011-2015 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
*/
|
||||
|
||||
.btn-default,
|
||||
.btn-primary,
|
||||
.btn-success,
|
||||
.btn-info,
|
||||
.btn-warning,
|
||||
.btn-danger {
|
||||
text-shadow: 0 -1px 0 rgba(0, 0, 0, .2);
|
||||
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075);
|
||||
}
|
||||
.btn-default:active,
|
||||
.btn-primary:active,
|
||||
.btn-success:active,
|
||||
.btn-info:active,
|
||||
.btn-warning:active,
|
||||
.btn-danger:active,
|
||||
.btn-default.active,
|
||||
.btn-primary.active,
|
||||
.btn-success.active,
|
||||
.btn-info.active,
|
||||
.btn-warning.active,
|
||||
.btn-danger.active {
|
||||
-webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
|
||||
box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
|
||||
}
|
||||
.btn-default .badge,
|
||||
.btn-primary .badge,
|
||||
.btn-success .badge,
|
||||
.btn-info .badge,
|
||||
.btn-warning .badge,
|
||||
.btn-danger .badge {
|
||||
text-shadow: none;
|
||||
}
|
||||
.btn:active,
|
||||
.btn.active {
|
||||
background-image: none;
|
||||
}
|
||||
.btn-default {
|
||||
text-shadow: 0 1px 0 #fff;
|
||||
background-image: -webkit-linear-gradient(top, #fff 0%, #e0e0e0 100%);
|
||||
background-image: -o-linear-gradient(top, #fff 0%, #e0e0e0 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#e0e0e0));
|
||||
background-image: linear-gradient(to bottom, #fff 0%, #e0e0e0 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #dbdbdb;
|
||||
border-color: #ccc;
|
||||
}
|
||||
.btn-default:hover,
|
||||
.btn-default:focus {
|
||||
background-color: #e0e0e0;
|
||||
background-position: 0 -15px;
|
||||
}
|
||||
.btn-default:active,
|
||||
.btn-default.active {
|
||||
background-color: #e0e0e0;
|
||||
border-color: #dbdbdb;
|
||||
}
|
||||
.btn-default.disabled,
|
||||
.btn-default:disabled,
|
||||
.btn-default[disabled] {
|
||||
background-color: #e0e0e0;
|
||||
background-image: none;
|
||||
}
|
||||
.btn-primary {
|
||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #265a88 100%);
|
||||
background-image: -o-linear-gradient(top, #337ab7 0%, #265a88 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#265a88));
|
||||
background-image: linear-gradient(to bottom, #337ab7 0%, #265a88 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #245580;
|
||||
}
|
||||
.btn-primary:hover,
|
||||
.btn-primary:focus {
|
||||
background-color: #265a88;
|
||||
background-position: 0 -15px;
|
||||
}
|
||||
.btn-primary:active,
|
||||
.btn-primary.active {
|
||||
background-color: #265a88;
|
||||
border-color: #245580;
|
||||
}
|
||||
.btn-primary.disabled,
|
||||
.btn-primary:disabled,
|
||||
.btn-primary[disabled] {
|
||||
background-color: #265a88;
|
||||
background-image: none;
|
||||
}
|
||||
.btn-success {
|
||||
background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%);
|
||||
background-image: -o-linear-gradient(top, #5cb85c 0%, #419641 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#419641));
|
||||
background-image: linear-gradient(to bottom, #5cb85c 0%, #419641 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #3e8f3e;
|
||||
}
|
||||
.btn-success:hover,
|
||||
.btn-success:focus {
|
||||
background-color: #419641;
|
||||
background-position: 0 -15px;
|
||||
}
|
||||
.btn-success:active,
|
||||
.btn-success.active {
|
||||
background-color: #419641;
|
||||
border-color: #3e8f3e;
|
||||
}
|
||||
.btn-success.disabled,
|
||||
.btn-success:disabled,
|
||||
.btn-success[disabled] {
|
||||
background-color: #419641;
|
||||
background-image: none;
|
||||
}
|
||||
.btn-info {
|
||||
background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);
|
||||
background-image: -o-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#2aabd2));
|
||||
background-image: linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #28a4c9;
|
||||
}
|
||||
.btn-info:hover,
|
||||
.btn-info:focus {
|
||||
background-color: #2aabd2;
|
||||
background-position: 0 -15px;
|
||||
}
|
||||
.btn-info:active,
|
||||
.btn-info.active {
|
||||
background-color: #2aabd2;
|
||||
border-color: #28a4c9;
|
||||
}
|
||||
.btn-info.disabled,
|
||||
.btn-info:disabled,
|
||||
.btn-info[disabled] {
|
||||
background-color: #2aabd2;
|
||||
background-image: none;
|
||||
}
|
||||
.btn-warning {
|
||||
background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);
|
||||
background-image: -o-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#eb9316));
|
||||
background-image: linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #e38d13;
|
||||
}
|
||||
.btn-warning:hover,
|
||||
.btn-warning:focus {
|
||||
background-color: #eb9316;
|
||||
background-position: 0 -15px;
|
||||
}
|
||||
.btn-warning:active,
|
||||
.btn-warning.active {
|
||||
background-color: #eb9316;
|
||||
border-color: #e38d13;
|
||||
}
|
||||
.btn-warning.disabled,
|
||||
.btn-warning:disabled,
|
||||
.btn-warning[disabled] {
|
||||
background-color: #eb9316;
|
||||
background-image: none;
|
||||
}
|
||||
.btn-danger {
|
||||
background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%);
|
||||
background-image: -o-linear-gradient(top, #d9534f 0%, #c12e2a 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c12e2a));
|
||||
background-image: linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #b92c28;
|
||||
}
|
||||
.btn-danger:hover,
|
||||
.btn-danger:focus {
|
||||
background-color: #c12e2a;
|
||||
background-position: 0 -15px;
|
||||
}
|
||||
.btn-danger:active,
|
||||
.btn-danger.active {
|
||||
background-color: #c12e2a;
|
||||
border-color: #b92c28;
|
||||
}
|
||||
.btn-danger.disabled,
|
||||
.btn-danger:disabled,
|
||||
.btn-danger[disabled] {
|
||||
background-color: #c12e2a;
|
||||
background-image: none;
|
||||
}
|
||||
.thumbnail,
|
||||
.img-thumbnail {
|
||||
-webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
|
||||
}
|
||||
.dropdown-menu > li > a:hover,
|
||||
.dropdown-menu > li > a:focus {
|
||||
background-color: #e8e8e8;
|
||||
background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
|
||||
background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8));
|
||||
background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.dropdown-menu > .active > a,
|
||||
.dropdown-menu > .active > a:hover,
|
||||
.dropdown-menu > .active > a:focus {
|
||||
background-color: #2e6da4;
|
||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
||||
background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
|
||||
background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.navbar-default {
|
||||
background-image: -webkit-linear-gradient(top, #fff 0%, #f8f8f8 100%);
|
||||
background-image: -o-linear-gradient(top, #fff 0%, #f8f8f8 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#f8f8f8));
|
||||
background-image: linear-gradient(to bottom, #fff 0%, #f8f8f8 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
||||
background-repeat: repeat-x;
|
||||
border-radius: 4px;
|
||||
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075);
|
||||
}
|
||||
.navbar-default .navbar-nav > .open > a,
|
||||
.navbar-default .navbar-nav > .active > a {
|
||||
background-image: -webkit-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);
|
||||
background-image: -o-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#dbdbdb), to(#e2e2e2));
|
||||
background-image: linear-gradient(to bottom, #dbdbdb 0%, #e2e2e2 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
-webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075);
|
||||
box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075);
|
||||
}
|
||||
.navbar-brand,
|
||||
.navbar-nav > li > a {
|
||||
text-shadow: 0 1px 0 rgba(255, 255, 255, .25);
|
||||
}
|
||||
.navbar-inverse {
|
||||
background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222 100%);
|
||||
background-image: -o-linear-gradient(top, #3c3c3c 0%, #222 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#3c3c3c), to(#222));
|
||||
background-image: linear-gradient(to bottom, #3c3c3c 0%, #222 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.navbar-inverse .navbar-nav > .open > a,
|
||||
.navbar-inverse .navbar-nav > .active > a {
|
||||
background-image: -webkit-linear-gradient(top, #080808 0%, #0f0f0f 100%);
|
||||
background-image: -o-linear-gradient(top, #080808 0%, #0f0f0f 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#080808), to(#0f0f0f));
|
||||
background-image: linear-gradient(to bottom, #080808 0%, #0f0f0f 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
-webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25);
|
||||
box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25);
|
||||
}
|
||||
.navbar-inverse .navbar-brand,
|
||||
.navbar-inverse .navbar-nav > li > a {
|
||||
text-shadow: 0 -1px 0 rgba(0, 0, 0, .25);
|
||||
}
|
||||
.navbar-static-top,
|
||||
.navbar-fixed-top,
|
||||
.navbar-fixed-bottom {
|
||||
border-radius: 0;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.navbar .navbar-nav .open .dropdown-menu > .active > a,
|
||||
.navbar .navbar-nav .open .dropdown-menu > .active > a:hover,
|
||||
.navbar .navbar-nav .open .dropdown-menu > .active > a:focus {
|
||||
color: #fff;
|
||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
||||
background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
|
||||
background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
}
|
||||
.alert {
|
||||
text-shadow: 0 1px 0 rgba(255, 255, 255, .2);
|
||||
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05);
|
||||
}
|
||||
.alert-success {
|
||||
background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);
|
||||
background-image: -o-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#c8e5bc));
|
||||
background-image: linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #b2dba1;
|
||||
}
|
||||
.alert-info {
|
||||
background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%);
|
||||
background-image: -o-linear-gradient(top, #d9edf7 0%, #b9def0 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#b9def0));
|
||||
background-image: linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #9acfea;
|
||||
}
|
||||
.alert-warning {
|
||||
background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);
|
||||
background-image: -o-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#f8efc0));
|
||||
background-image: linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #f5e79e;
|
||||
}
|
||||
.alert-danger {
|
||||
background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);
|
||||
background-image: -o-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#e7c3c3));
|
||||
background-image: linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #dca7a7;
|
||||
}
|
||||
.progress {
|
||||
background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);
|
||||
background-image: -o-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#ebebeb), to(#f5f5f5));
|
||||
background-image: linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.progress-bar {
|
||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #286090 100%);
|
||||
background-image: -o-linear-gradient(top, #337ab7 0%, #286090 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#286090));
|
||||
background-image: linear-gradient(to bottom, #337ab7 0%, #286090 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.progress-bar-success {
|
||||
background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%);
|
||||
background-image: -o-linear-gradient(top, #5cb85c 0%, #449d44 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#449d44));
|
||||
background-image: linear-gradient(to bottom, #5cb85c 0%, #449d44 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.progress-bar-info {
|
||||
background-image: -webkit-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);
|
||||
background-image: -o-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#31b0d5));
|
||||
background-image: linear-gradient(to bottom, #5bc0de 0%, #31b0d5 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.progress-bar-warning {
|
||||
background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);
|
||||
background-image: -o-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#ec971f));
|
||||
background-image: linear-gradient(to bottom, #f0ad4e 0%, #ec971f 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.progress-bar-danger {
|
||||
background-image: -webkit-linear-gradient(top, #d9534f 0%, #c9302c 100%);
|
||||
background-image: -o-linear-gradient(top, #d9534f 0%, #c9302c 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c9302c));
|
||||
background-image: linear-gradient(to bottom, #d9534f 0%, #c9302c 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.progress-bar-striped {
|
||||
background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
|
||||
background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
|
||||
background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
|
||||
}
|
||||
.list-group {
|
||||
border-radius: 4px;
|
||||
-webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
|
||||
}
|
||||
.list-group-item.active,
|
||||
.list-group-item.active:hover,
|
||||
.list-group-item.active:focus {
|
||||
text-shadow: 0 -1px 0 #286090;
|
||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #2b669a 100%);
|
||||
background-image: -o-linear-gradient(top, #337ab7 0%, #2b669a 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2b669a));
|
||||
background-image: linear-gradient(to bottom, #337ab7 0%, #2b669a 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #2b669a;
|
||||
}
|
||||
.list-group-item.active .badge,
|
||||
.list-group-item.active:hover .badge,
|
||||
.list-group-item.active:focus .badge {
|
||||
text-shadow: none;
|
||||
}
|
||||
.panel {
|
||||
-webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .05);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .05);
|
||||
}
|
||||
.panel-default > .panel-heading {
|
||||
background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
|
||||
background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8));
|
||||
background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.panel-primary > .panel-heading {
|
||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
||||
background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
|
||||
background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.panel-success > .panel-heading {
|
||||
background-image: -webkit-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);
|
||||
background-image: -o-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#d0e9c6));
|
||||
background-image: linear-gradient(to bottom, #dff0d8 0%, #d0e9c6 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.panel-info > .panel-heading {
|
||||
background-image: -webkit-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);
|
||||
background-image: -o-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#c4e3f3));
|
||||
background-image: linear-gradient(to bottom, #d9edf7 0%, #c4e3f3 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.panel-warning > .panel-heading {
|
||||
background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);
|
||||
background-image: -o-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#faf2cc));
|
||||
background-image: linear-gradient(to bottom, #fcf8e3 0%, #faf2cc 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.panel-danger > .panel-heading {
|
||||
background-image: -webkit-linear-gradient(top, #f2dede 0%, #ebcccc 100%);
|
||||
background-image: -o-linear-gradient(top, #f2dede 0%, #ebcccc 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#ebcccc));
|
||||
background-image: linear-gradient(to bottom, #f2dede 0%, #ebcccc 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.well {
|
||||
background-image: -webkit-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);
|
||||
background-image: -o-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#e8e8e8), to(#f5f5f5));
|
||||
background-image: linear-gradient(to bottom, #e8e8e8 0%, #f5f5f5 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #dcdcdc;
|
||||
-webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1);
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1);
|
||||
}
|
||||
/*# sourceMappingURL=bootstrap-theme.css.map */
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -1,10 +0,0 @@
|
|||
<footer>
|
||||
<hr />
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-xs-12 text-right">
|
||||
@Model.FooterLogoDark
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
|
@ -1,43 +0,0 @@
|
|||
@Each.Messages
|
||||
@Current.ToDisplay
|
||||
@EndEach
|
||||
@If.SubTitle.IsSome
|
||||
<h2>
|
||||
<span class="label label-info">@Model.SubTitle</span>
|
||||
</h2>
|
||||
@EndIf
|
||||
@Each.Posts
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<article>
|
||||
<h1>
|
||||
<a href="/@Current.Post.Permalink"
|
||||
title="@Translate.PermanentLinkTo "@Current.Post.Title"">@Current.Post.Title</a>
|
||||
</h1>
|
||||
<p>
|
||||
<i class="fa fa-calendar" title="@Translate.Date"></i> @Current.PublishedDate
|
||||
<i class="fa fa-clock-o" title="@Translate.Time"></i> @Current.PublishedTime
|
||||
<i class="fa fa-comments-o" title="@Translate.Comments"></i> @Current.CommentCount
|
||||
</p>
|
||||
@Current.Post.Text
|
||||
</article>
|
||||
<hr />
|
||||
</div>
|
||||
</div>
|
||||
@EndEach
|
||||
<div class="row">
|
||||
<div class="col-xs-3 col-xs-offset-3">
|
||||
@If.HasNewer
|
||||
<p>
|
||||
<a class="btn btn-primary" href="@Model.NewerLink">@Translate.NewerPosts</a>
|
||||
</p>
|
||||
@EndIf
|
||||
</div>
|
||||
<div class="col-xs-3 text-right">
|
||||
@If.HasOlder
|
||||
<p>
|
||||
<a class="btn btn-primary" href="@Model.OlderLink">@Translate.OlderPosts</a>
|
||||
</p>
|
||||
@EndIf
|
||||
</div>
|
||||
</div>
|
|
@ -1,9 +0,0 @@
|
|||
@Master['themes/default/layout']
|
||||
|
||||
@Section['Content']
|
||||
@Partial['themes/default/index-content', Model]
|
||||
@EndSection
|
||||
|
||||
@Section['Footer']
|
||||
@Partial['themes/default/footer', Model]
|
||||
@EndSection
|
|
@ -1,48 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="generator" content="@Model.Generator" />
|
||||
<title>@Model.DisplayPageTitle</title>
|
||||
<link rel="stylesheet" type="text/css" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.4/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" type="text/css" href="/default/bootstrap-theme.min.css" />
|
||||
<link rel="stylesheet" type="text/css" href="//maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css" />
|
||||
<link rel="alternate" type="application/atom+xml" href="//@Model.WebLog.UrlBase/feed?format=atom" />
|
||||
<link rel="alternate" type="application/rss+xml" href="//@Model.WebLog.UrlBase/feed" />
|
||||
@Section['Head'];
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav class="navbar navbar-default">
|
||||
<div class="container-fluid">
|
||||
<div class="navbar-header">
|
||||
<a class="navbar-brand" href="/">@Model.WebLog.Name</a>
|
||||
</div>
|
||||
<p class="navbar-text">@Model.WebLogSubtitle</p>
|
||||
<ul class="nav navbar-nav navbar-left">
|
||||
@Each.WebLog.PageList
|
||||
<li><a href="/@Current.Permalink">@Current.Title</a></li>
|
||||
@EndEach
|
||||
</ul>
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
@If.IsAuthenticated
|
||||
<li><a href="/admin">@Translate.Dashboard</a></li>
|
||||
<li><a href="/user/log-off">@Translate.LogOff</a></li>
|
||||
@EndIf
|
||||
@IfNot.IsAuthenticated
|
||||
<li><a href="/user/log-on">@Translate.LogOn</a></li>
|
||||
@EndIf
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="container">
|
||||
@Section['Content'];
|
||||
</div>
|
||||
@Section['Footer'];
|
||||
<script type="text/javascript" src="//ajax.aspnetcdn.com/ajax/jQuery/jquery-2.1.3.min.js"></script>
|
||||
<script type="text/javascript" src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.4/js/bootstrap.min.js"></script>
|
||||
@Section['Scripts'];
|
||||
</body>
|
||||
</html>
|
|
@ -1,4 +0,0 @@
|
|||
<article>
|
||||
<h1>@Model.Page.Title</h1>
|
||||
@Model.Page.Text
|
||||
</article>
|
|
@ -1,9 +0,0 @@
|
|||
@Master['themes/default/layout']
|
||||
|
||||
@Section['Content']
|
||||
@Partial['themes/default/page-content', Model]
|
||||
@EndSection
|
||||
|
||||
@Section['Footer']
|
||||
@Partial['themes/default/footer', Model]
|
||||
@EndSection
|
|
@ -1,67 +0,0 @@
|
|||
<article>
|
||||
<div class="row">
|
||||
<div class="col-xs-12"><h1>@Model.Post.Title</h1></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<h4>
|
||||
<i class="fa fa-calendar" title="@Translate.Date"></i> @Model.PublishedDate
|
||||
<i class="fa fa-clock-o" title="@Translate.Time"></i> @Model.PublishedTime
|
||||
<i class="fa fa-comments-o" title="@Translate.Comments"></i> @Model.CommentCount
|
||||
@Each.Post.Categories
|
||||
<span style="white-space:nowrap;">
|
||||
<i class="fa fa-folder-open-o" title="@Translate.Category"></i>
|
||||
<a href="/category/@Current.Slug" title="@Translate.CategorizedUnder @Current.Name">@Current.Name</a>
|
||||
|
||||
</span>
|
||||
@EndEach
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12">@Model.Post.Text</div>
|
||||
</div>
|
||||
@If.HasTags
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
@Each.Tags
|
||||
<span style="white-space:nowrap;">
|
||||
<a href="/tag/@Current.Item2" title="@Translate.PostsTagged "@Current.Item1"">
|
||||
<i class="fa fa-tag"></i> @Current.Item1
|
||||
</a>
|
||||
</span>
|
||||
@EndEach
|
||||
</div>
|
||||
</div>
|
||||
@EndIf
|
||||
</article>
|
||||
<div class="row">
|
||||
<div class="col-xs-12"><hr /></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
@Each.Comments
|
||||
@Partial['themes/default/comment', @Current]
|
||||
@EndEach
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12"><hr /></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
@If.HasNewer
|
||||
<a href="/@Model.NewerPost.Value.Permalink" title="@Translate.NextPost - "@Model.NewerPost.Value.Title"">
|
||||
« @Model.NewerPost.Value.Title
|
||||
</a>
|
||||
@EndIf
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
@If.HasOlder
|
||||
<a href="/@Model.OlderPost.Value.Permalink"
|
||||
title="@Translate.PreviousPost - "@Model.OlderPost.Value.Title"">
|
||||
@Model.OlderPost.Value.Title »
|
||||
</a>
|
||||
@EndIf
|
||||
</div>
|
||||
</div>
|
|
@ -1,9 +0,0 @@
|
|||
@Master['themes/default/layout']
|
||||
|
||||
@Section['Content']
|
||||
@Partial['themes/default/single-content', Model]
|
||||
@EndSection
|
||||
|
||||
@Section['Footer']
|
||||
@Partial['themes/default/footer', Model]
|
||||
@EndSection
|
|
@ -1,5 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FSharpLintSettings xmlns="https://github.com/fsprojects/FSharpLint/blob/master/ConfigurationSchema.xsd">
|
||||
<IgnoreFiles Update="Overwrite"><![CDATA[assemblyinfo.*]]></IgnoreFiles>
|
||||
<Analysers />
|
||||
</FSharpLintSettings>
|
54
src/admin-theme/_layout.liquid
Normal file
54
src/admin-theme/_layout.liquid
Normal file
|
@ -0,0 +1,54 @@
|
|||
<header>
|
||||
<nav class="navbar navbar-dark bg-dark navbar-expand-md justify-content-start px-2 position-fixed top-0 w-100">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ "" | relative_link }}" hx-boost="false">{{ web_log.name }}</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarText"
|
||||
aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarText">
|
||||
{% if logged_on -%}
|
||||
<ul class="navbar-nav">
|
||||
{{ "admin/dashboard" | nav_link: "Dashboard" }}
|
||||
{{ "admin/pages" | nav_link: "Pages" }}
|
||||
{{ "admin/posts" | nav_link: "Posts" }}
|
||||
{{ "admin/categories" | nav_link: "Categories" }}
|
||||
{{ "admin/settings" | nav_link: "Settings" }}
|
||||
</ul>
|
||||
{%- endif %}
|
||||
<ul class="navbar-nav flex-grow-1 justify-content-end">
|
||||
{% if logged_on -%}
|
||||
{{ "admin/user/edit" | nav_link: "Edit User" }}
|
||||
{{ "user/log-off" | nav_link: "Log Off" }}
|
||||
{%- else -%}
|
||||
{{ "user/log-on" | nav_link: "Log On" }}
|
||||
{%- endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="mx-3 mt-3">
|
||||
<div class="messages mt-2" id="msgContainer">
|
||||
{% for msg in messages %}
|
||||
<div role="alert" class="alert alert-{{ msg.level }} alert-dismissible fade show">
|
||||
{{ msg.message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
{% if msg.detail %}
|
||||
<hr>
|
||||
{{ msg.detail.value }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{{ content }}
|
||||
</main>
|
||||
<footer class="position-fixed bottom-0 w-100">
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-xs-12 text-end">
|
||||
<img src="{{ "themes/admin/logo-light.png" | relative_link }}" alt="myWebLog" width="120" height="34">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
54
src/admin-theme/category-edit.liquid
Normal file
54
src/admin-theme/category-edit.liquid
Normal file
|
@ -0,0 +1,54 @@
|
|||
<div class="col-12">
|
||||
<h5 class="my-3">{{ page_title }}</h5>
|
||||
<form hx-post="{{ "admin/category/save" | relative_link }}" method="post" class="container"
|
||||
hx-target="#catList" hx-swap="outerHTML show:window:top">
|
||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||
<input type="hidden" name="categoryId" value="{{ model.category_id }}">
|
||||
<div class="row">
|
||||
<div class="col-12 col-sm-6 col-lg-4 col-xxl-3 offset-xxl-1 mb-3">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="name" id="name" class="form-control form-control-sm" placeholder="Name" autofocus
|
||||
required value="{{ model.name | escape }}">
|
||||
<label for="name">Name</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-lg-4 col-xxl-3 mb-3">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="slug" id="slug" class="form-control form-control-sm" placeholder="Slug" required
|
||||
value="{{ model.slug | escape }}">
|
||||
<label for="slug">Slug</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4 col-xxl-3 offset-xxl-1 mb-3">
|
||||
<div class="form-floating">
|
||||
<select name="parentId" id="parentId" class="form-control form-control-sm">
|
||||
<option value=""{% if model.parent_id == "" %} selected="selected"{% endif %}>
|
||||
– None –
|
||||
</option>
|
||||
{% for cat in categories -%}
|
||||
{%- unless cat.id == model.category_id %}
|
||||
<option value="{{ cat.id }}"{% if model.parent_id == cat.id %} selected="selected"{% endif %}>
|
||||
{% for it in cat.parent_names %} » {% endfor %}{{ cat.name }}
|
||||
</option>
|
||||
{% endunless -%}
|
||||
{%- endfor %}
|
||||
</select>
|
||||
<label for="parentId">Parent Category</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-10 offset-xl-1 mb-3">
|
||||
<div class="form-floating">
|
||||
<input name="description" id="description" class="form-control form-control-sm"
|
||||
placeholder="A short description of this category" value="{{ model.description | escape }}">
|
||||
<label for="description">Description</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col text-center">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Save Changes</button>
|
||||
<a href="{{ "admin/categories/bare" | relative_link }}" class="btn btn-sm btn-secondary ms-3">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
46
src/admin-theme/category-list-body.liquid
Normal file
46
src/admin-theme/category-list-body.liquid
Normal file
|
@ -0,0 +1,46 @@
|
|||
<form method="post" id="catList" class="container" hx-target="this" hx-swap="outerHTML show:window:top">
|
||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||
<div class="row mwl-table-detail" id="cat_new"></div>
|
||||
{%- assign cat_count = categories | size -%}
|
||||
{% if cat_count > 0 %}
|
||||
{%- assign cat_col = "col-12 col-md-6 col-xl-5 col-xxl-4" -%}
|
||||
{%- assign desc_col = "col-12 col-md-6 col-xl-7 col-xxl-8" -%}
|
||||
{% for cat in categories -%}
|
||||
<div class="row mwl-table-detail" id="cat_{{ cat.id }}">
|
||||
<div class="{{ cat_col }} no-wrap">
|
||||
{%- if cat.parent_names %}
|
||||
<small class="text-muted">{% for name in cat.parent_names %}{{ name }} ⟩ {% endfor %}</small>
|
||||
{%- endif %}
|
||||
{{ cat.name }}<br>
|
||||
<small>
|
||||
{%- if cat.post_count > 0 %}
|
||||
<a href="{{ cat | category_link }}" target="_blank">
|
||||
View {{ cat.post_count }} Post{% unless cat.post_count == 1 %}s{% endunless -%}
|
||||
</a>
|
||||
<span class="text-muted"> • </span>
|
||||
{%- endif %}
|
||||
{%- capture cat_edit %}admin/category/{{ cat.id }}/edit{% endcapture -%}
|
||||
<a href="{{ cat_edit | relative_link }}" hx-target="#cat_{{ cat.id }}"
|
||||
hx-swap="innerHTML show:#cat_{{ cat.id }}:top">
|
||||
Edit
|
||||
</a>
|
||||
<span class="text-muted"> • </span>
|
||||
{%- capture cat_del %}admin/category/{{ cat.id }}/delete{% endcapture -%}
|
||||
{%- capture cat_del_link %}{{ cat_del | relative_link }}{% endcapture -%}
|
||||
<a href="{{ cat_del_link }}" hx-post="{{ cat_del_link }}" class="text-danger"
|
||||
hx-confirm="Are you sure you want to delete the category “{{ cat.name }}”? This action cannot be undone.">
|
||||
Delete
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
<div class="{{ desc_col }}">
|
||||
{%- if cat.description %}{{ cat.description.value }}{% else %}<em class="text-muted">none</em>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{%- endfor %}
|
||||
{%- else -%}
|
||||
<div class="row">
|
||||
<div class="col-12 text-muted fst-italic text-center">This web log has no categores defined</div>
|
||||
</div>
|
||||
{%- endif %}
|
||||
</form>
|
16
src/admin-theme/category-list.liquid
Normal file
16
src/admin-theme/category-list.liquid
Normal file
|
@ -0,0 +1,16 @@
|
|||
<h2 class="my-3">{{ page_title }}</h2>
|
||||
<article>
|
||||
<a href="{{ "admin/category/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3"
|
||||
hx-target="#cat_new">
|
||||
Add a New Category
|
||||
</a>
|
||||
<div class="container">
|
||||
{%- assign cat_col = "col-12 col-md-6 col-xl-5 col-xxl-4" -%}
|
||||
{%- assign desc_col = "col-12 col-md-6 col-xl-7 col-xxl-8" -%}
|
||||
<div class="row mwl-table-heading">
|
||||
<div class="{{ cat_col }}">Category<span class="d-md-none">; Description</span></div>
|
||||
<div class="{{ desc_col }} d-none d-md-inline-block">Description</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ category_list }}
|
||||
</article>
|
208
src/admin-theme/custom-feed-edit.liquid
Normal file
208
src/admin-theme/custom-feed-edit.liquid
Normal file
|
@ -0,0 +1,208 @@
|
|||
<h2 class="my-3">{{ page_title }}</h2>
|
||||
<article>
|
||||
<form action="{{ "admin/settings/rss/save" | relative_link }}" method="post">
|
||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||
<input type="hidden" name="id" value="{{ model.id }}">
|
||||
{%- assign typ = model.source_type -%}
|
||||
<div class="container">
|
||||
<div class="row pb-3">
|
||||
<div class="col">
|
||||
<a href="{{ "admin/settings/rss" | relative_link }}">« Back to RSS Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row pb-3">
|
||||
<div class="col-12 col-lg-6">
|
||||
<fieldset class="container pb-0">
|
||||
<legend>Identification</legend>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="path" id="path" class="form-control" placeholder="Relative Feed Path"
|
||||
value="{{ model.path }}">
|
||||
<label for="path">Relative Feed Path</label>
|
||||
<span class="form-text fst-italic">Appended to {{ web_log.url_base }}/</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col py-3 d-flex align-self-center justify-content-center">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" name="isPodcast" id="isPodcast" class="form-check-input" value="true"
|
||||
{%- if model.is_podcast %} checked="checked"{% endif %} onclick="Admin.checkPodcast()">
|
||||
<label for="isPodcast" class="form-check-label">This Is a Podcast Feed</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<fieldset class="container pb-0">
|
||||
<legend>Feed Source</legend>
|
||||
<div class="row d-flex align-items-center">
|
||||
<div class="col-1 d-flex justify-content-end pb-3">
|
||||
<div class="form-check form-check-inline me-0">
|
||||
<input type="radio" name="sourceType" id="sourceTypeCat" class="form-check-input" value="category"
|
||||
{%- unless typ == "tag" %} checked="checked" {% endunless -%}
|
||||
onclick="Admin.customFeedBy('category')">
|
||||
<label for="sourceTypeCat" class="form-check-label d-none">Category</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-11 pb-3">
|
||||
<div class="form-floating">
|
||||
<select name="sourceValue" id="sourceValueCat" class="form-control" required
|
||||
{%- if typ == "tag" %} disabled="disabled"{% endif %}>
|
||||
<option value="">– Select Category –</option>
|
||||
{% for cat in categories -%}
|
||||
<option value="{{ cat.id }}"
|
||||
{%- if typ != "tag" and model.source_value == cat.id %} selected="selected"{% endif -%}>
|
||||
{% for it in cat.parent_names %}{{ it }} ⟩ {% endfor %}{{ cat.name }}
|
||||
</option>
|
||||
{%- endfor %}
|
||||
</select>
|
||||
<label for="sourceValueCat">Category</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-1 d-flex justify-content-end pb-3">
|
||||
<div class="form-check form-check-inline me-0">
|
||||
<input type="radio" name="sourceType" id="sourceTypeTag" class="form-check-input" value="tag"
|
||||
{%- if typ == "tag" %} checked="checked"{% endif %} onclick="Admin.customFeedBy('tag')">
|
||||
<label for="sourceTypeTag" class="form-check-label d-none">Tag</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-11 pb-3">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="sourceValue" id="sourceValueTag" class="form-control" placeholder="Tag"
|
||||
{%- unless typ == "tag" %} disabled="disabled"{% endunless %} required
|
||||
{%- if typ == "tag" %} value="{{ model.source_value }}"{% endif %}>
|
||||
<label for="sourceValueTag">Tag</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row pb-3">
|
||||
<div class="col">
|
||||
<fieldset class="container" id="podcastFields"{% unless model.is_podcast %} disabled="disabled"{%endunless%}>
|
||||
<legend>Podcast Settings</legend>
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-5 col-lg-4 offset-lg-1 pb-3">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="title" id="title" class="form-control" placeholder="Title" required
|
||||
value="{{ model.title }}">
|
||||
<label for="title">Title</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 col-lg-4 pb-3">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="subtitle" id="subtitle" class="form-control" placeholder="Subtitle"
|
||||
value="{{ model.subtitle }}">
|
||||
<label for="subtitle">Podcast Subtitle</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-3 col-lg-2 pb-3">
|
||||
<div class="form-floating">
|
||||
<input type="number" name="itemsInFeed" id="itemsInFeed" class="form-control" placeholder="Items"
|
||||
required value="{{ model.items_in_feed }}">
|
||||
<label for="itemsInFeed"># Episodes</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-5 col-lg-4 offset-lg-1 pb-3">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="itunesCategory" id="itunesCategory" class="form-control"
|
||||
placeholder="iTunes Category" required value="{{ model.itunes_category }}">
|
||||
<label for="itunesCategory">iTunes Category</label>
|
||||
<span class="form-text fst-italic">
|
||||
<a href="https://www.thepodcasthost.com/planning/itunes-podcast-categories/" target="_blank"
|
||||
rel="noopener">
|
||||
iTunes Category / Subcategory List
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 pb-3">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="itunesSubcategory" id="itunesSubcategory" class="form-control"
|
||||
placeholder="iTunes Subcategory" value="{{ model.itunes_subcategory }}">
|
||||
<label for="itunesSubcategory">iTunes Subcategory</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-3 col-lg-2 pb-3">
|
||||
<div class="form-floating">
|
||||
<select name="explicit" id="explicit" class="form-control" required>
|
||||
<option value="yes"{% if model.explicit == "yes" %} selected="selected"{% endif %}>Yes</option>
|
||||
<option value="no"{% if model.explicit == "no" %} selected="selected"{% endif %}>No</option>
|
||||
<option value="clean"{% if model.explicit == "clean" %} selected="selected"{% endif %}>
|
||||
Clean
|
||||
</option>
|
||||
</select>
|
||||
<label for="explicit">Explicit Rating</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-6 col-lg-4 offset-xxl-1 pb-3">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="displayedAuthor" id="displayedAuthor" class="form-control"
|
||||
placeholder="Author" required value="{{ model.displayed_author }}">
|
||||
<label for="displayedAuthor">Displayed Author</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-4 pb-3">
|
||||
<div class="form-floating">
|
||||
<input type="email" name="email" id="email" class="form-control" placeholder="Email" required
|
||||
value="{{ model.email }}">
|
||||
<label for="email">Author E-mail</label>
|
||||
<span class="form-text fst-italic">For iTunes, must match registered e-mail</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-5 col-md-4 col-lg-4 col-xl-3 offset-xl-1 col-xxl-2 offset-xxl-0">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="defaultMediaType" id="defaultMediaType" class="form-control"
|
||||
placeholder="Media Type" value="{{ model.default_media_type }}">
|
||||
<label for="defaultMediaType">Default Media Type</label>
|
||||
<span class="form-text fst-italic">Optional; blank for no default</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-7 col-md-8 col-lg-10 offset-lg-1">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="imageUrl" id="imageUrl" class="form-control" placeholder="Image URL" required
|
||||
value="{{ model.image_url }}">
|
||||
<label for="imageUrl">Image URL</label>
|
||||
<span class="form-text fst-italic">Relative URL will be appended to {{ web_log.url_base }}/</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row pb-3">
|
||||
<div class="col-12 col-lg-10 offset-lg-1">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="summary" id="summary" class="form-control" placeholder="Summary" required
|
||||
value="{{ model.summary }}">
|
||||
<label for="summary">Summary</label>
|
||||
<span class="form-text fst-italic">Displayed in podcast directories</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-10 offset-lg-1">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="mediaBaseUrl" id="mediaBaseUrl" class="form-control"
|
||||
placeholder="Media Base URL" value="{{ model.media_base_url }}">
|
||||
<label for="mediaBaseUrl">Media Base URL</label>
|
||||
<span class="form-text fst-italic">Optional; prepended to episode media file if present</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row pb-3">
|
||||
<div class="col text-center">
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
51
src/admin-theme/dashboard.liquid
Normal file
51
src/admin-theme/dashboard.liquid
Normal file
|
@ -0,0 +1,51 @@
|
|||
<h2 class="my-3">{{ web_log.name }} • Dashboard</h2>
|
||||
<article class="container">
|
||||
<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">Posts</header>
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle text-muted pb-3">
|
||||
Published <span class="badge rounded-pill bg-secondary">{{ model.posts }}</span>
|
||||
Drafts <span class="badge rounded-pill bg-secondary">{{ model.drafts }}</span>
|
||||
</h6>
|
||||
<a href="{{ "admin/posts" | relative_link }}" class="btn btn-secondary me-2">View All</a>
|
||||
<a href="{{ "admin/post/new/edit" | relative_link }}" class="btn btn-primary">Write a New Post</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">Pages</header>
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle text-muted pb-3">
|
||||
All <span class="badge rounded-pill bg-secondary">{{ model.pages }}</span>
|
||||
Shown in Page List <span class="badge rounded-pill bg-secondary">{{ model.listed_pages }}</span>
|
||||
</h6>
|
||||
<a href="{{ "admin/pages" | relative_link }}" class="btn btn-secondary me-2">View All</a>
|
||||
<a href="{{ "admin/page/new/edit" | relative_link }}" class="btn btn-primary">Create a New Page</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">Categories</header>
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle text-muted pb-3">
|
||||
All <span class="badge rounded-pill bg-secondary">{{ model.categories }}</span>
|
||||
Top Level <span class="badge rounded-pill bg-secondary">{{ model.top_level_categories }}</span>
|
||||
</h6>
|
||||
<a href="{{ "admin/categories" | relative_link }}" class="btn btn-secondary me-2">View All</a>
|
||||
<a href="{{ "admin/category/new/edit" | relative_link }}" class="btn btn-secondary">Add a New Category</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="row pb-3">
|
||||
<div class="col text-end">
|
||||
<a href="{{ "admin/settings" | relative_link }}" class="btn btn-secondary">Modify Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
5
src/admin-theme/layout-bare.liquid
Normal file
5
src/admin-theme/layout-bare.liquid
Normal file
|
@ -0,0 +1,5 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><title></title></head>
|
||||
<body>{{ content }}</body>
|
||||
</html>
|
10
src/admin-theme/layout-partial.liquid
Normal file
10
src/admin-theme/layout-partial.liquid
Normal file
|
@ -0,0 +1,10 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{ page_title | strip_html }} « Admin « {{ web_log.name | strip_html }}</title>
|
||||
</head>
|
||||
<body>
|
||||
{% include_template "_layout" %}
|
||||
<script>Admin.dismissSuccesses()</script>
|
||||
</body>
|
||||
</html>
|
32
src/admin-theme/layout.liquid
Normal file
32
src/admin-theme/layout.liquid
Normal file
|
@ -0,0 +1,32 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="generator" content="{{ generator }}">
|
||||
<title>{{ page_title | strip_html }} « Admin « {{ web_log.name | strip_html }}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
|
||||
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="{{ "themes/admin/admin.css" | relative_link }}">
|
||||
</head>
|
||||
<body hx-boost="true">
|
||||
{% include_template "_layout" %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
|
||||
crossorigin="anonymous"></script>
|
||||
{{ htmx_script }}
|
||||
<script>
|
||||
const cssLoaded = [...document.styleSheets].filter(it => it.href.indexOf("bootstrap.min.css") > -1).length > 0
|
||||
if (!cssLoaded) {
|
||||
const local = document.createElement("link")
|
||||
local.rel = "stylesheet"
|
||||
local.href = "{{ "themes/admin/bootstrap.min.css" | relative_link }}"
|
||||
document.getElementsByTagName("link")[0].prepend(local)
|
||||
}
|
||||
setTimeout(function () {
|
||||
if (!bootstrap) document.write('<script src=\"{{ "script/bootstrap.bundle.min.js" | relative_link }}\"><\/script>')
|
||||
}, 2000)
|
||||
</script>
|
||||
<script src="{{ "themes/admin/admin.js" | relative_link }}"></script>
|
||||
<script>Admin.dismissSuccesses()</script>
|
||||
</body>
|
||||
</html>
|
30
src/admin-theme/log-on.liquid
Normal file
30
src/admin-theme/log-on.liquid
Normal file
|
@ -0,0 +1,30 @@
|
|||
<h2 class="my-3">Log On to {{ web_log.name }}</h2>
|
||||
<article class="py-3">
|
||||
<form action="{{ "user/log-on" | relative_link }}" method="post" hx-push-url="true">
|
||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||
{% if model.return_to %}
|
||||
<input type="hidden" name="returnTo" value="{{ model.return_to.value }}">
|
||||
{% endif %}
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-6 col-lg-4 offset-lg-2 pb-3">
|
||||
<div class="form-floating">
|
||||
<input type="email" id="email" name="emailAddress" class="form-control" autofocus required>
|
||||
<label for="email">E-mail Address</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-4 pb-3">
|
||||
<div class="form-floating">
|
||||
<input type="password" id="password" name="password" class="form-control" required>
|
||||
<label for="password">Password</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row pb-3">
|
||||
<div class="col text-center">
|
||||
<button type="submit" class="btn btn-primary">Log On</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user