From 0f66ca969d112a4075a56cb50836e8f311eb0580 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Wed, 22 Jun 2022 20:35:12 -0400 Subject: [PATCH] Version 2, ready for beta --- .gitignore | 12 +- src/MyWebLog.App/App.fs | 155 -- src/MyWebLog.App/AppConfig.fs | 33 - src/MyWebLog.App/AssemblyInfo.fs | 21 - src/MyWebLog.App/Data/Category.fs | 140 -- src/MyWebLog.App/Data/DataConfig.fs | 43 - src/MyWebLog.App/Data/Extensions.fs | 16 - src/MyWebLog.App/Data/Page.fs | 98 - src/MyWebLog.App/Data/Post.fs | 225 -- src/MyWebLog.App/Data/RethinkMyWebLogData.fs | 48 - src/MyWebLog.App/Data/SetUp.fs | 100 - src/MyWebLog.App/Data/Table.fs | 21 - src/MyWebLog.App/Data/User.fs | 31 - src/MyWebLog.App/Data/WebLog.fs | 39 - src/MyWebLog.App/Entities/Entities.fs | 301 --- src/MyWebLog.App/Entities/IMyWebLogData.fs | 117 - src/MyWebLog.App/Keys.fs | 17 - src/MyWebLog.App/Logic/Category.fs | 56 - src/MyWebLog.App/Logic/Page.fs | 29 - src/MyWebLog.App/Logic/Post.fs | 60 - src/MyWebLog.App/Logic/User.fs | 9 - src/MyWebLog.App/Logic/WebLog.fs | 11 - src/MyWebLog.App/Modules/AdminModule.fs | 22 - src/MyWebLog.App/Modules/CategoryModule.fs | 97 - src/MyWebLog.App/Modules/ModuleExtensions.fs | 36 - src/MyWebLog.App/Modules/PageModule.fs | 97 - src/MyWebLog.App/Modules/PostModule.fs | 317 --- src/MyWebLog.App/Modules/UserModule.fs | 64 - src/MyWebLog.App/MyWebLog.App.xproj | 21 - src/MyWebLog.App/Strings.fs | 42 - src/MyWebLog.App/ViewModels.fs | 504 ---- src/MyWebLog.App/en-US.json | 83 - src/MyWebLog.App/project.json | 64 - src/MyWebLog.Data/Converters.fs | 132 ++ src/MyWebLog.Data/Interfaces.fs | 281 +++ src/MyWebLog.Data/MyWebLog.Data.fsproj | 30 + src/MyWebLog.Data/RethinkDbData.fs | 885 ++++++++ src/MyWebLog.Data/SQLiteData.fs | 2017 +++++++++++++++++ src/MyWebLog.Data/Utils.fs | 22 + src/MyWebLog.Domain/DataTypes.fs | 427 ++++ src/MyWebLog.Domain/MyWebLog.Domain.fsproj | 20 + src/MyWebLog.Domain/SupportTypes.fs | 483 ++++ src/MyWebLog.Domain/ViewModels.fs | 740 ++++++ src/MyWebLog.Migrate/Program.fs | 178 -- src/MyWebLog.Migrate/project.json | 33 - src/MyWebLog.Tests/MyWebLog.Tests.fs | 4 - src/MyWebLog.Tests/MyWebLog.Tests.fsproj | 70 - src/MyWebLog.sln | 37 + src/MyWebLog/Caches.fs | 157 ++ src/MyWebLog/DotLiquidBespoke.fs | 237 ++ src/MyWebLog/Handlers/Admin.fs | 504 ++++ src/MyWebLog/Handlers/Error.fs | 22 + src/MyWebLog/Handlers/Feed.fs | 459 ++++ src/MyWebLog/Handlers/Helpers.fs | 256 +++ src/MyWebLog/Handlers/Post.fs | 365 +++ src/MyWebLog/Handlers/Routes.fs | 240 ++ src/MyWebLog/Handlers/User.fs | 118 + src/MyWebLog/Maintenance.fs | 395 ++++ src/MyWebLog/MyWebLog.fsproj | 44 + src/MyWebLog/MyWebLog.xproj | 21 - src/MyWebLog/Program.cs | 10 - src/MyWebLog/Program.fs | 147 ++ src/MyWebLog/Properties/AssemblyInfo.cs | 15 - src/MyWebLog/appsettings.json | 12 + src/MyWebLog/config.json | 17 - src/MyWebLog/project.json | 26 - src/MyWebLog/views/admin/admin-layout.html | 52 - src/MyWebLog/views/admin/category/edit.html | 55 - src/MyWebLog/views/admin/category/list.html | 51 - src/MyWebLog/views/admin/content/admin.css | 5 - .../views/admin/content/tinymce-init.js | 10 - src/MyWebLog/views/admin/dashboard.html | 31 - src/MyWebLog/views/admin/page/edit.html | 61 - src/MyWebLog/views/admin/page/list.html | 42 - src/MyWebLog/views/admin/post/edit.html | 90 - src/MyWebLog/views/admin/post/list.html | 49 - src/MyWebLog/views/admin/user/log-on.html | 41 - .../views/themes/default/comment.html | 4 - .../default/content/bootstrap-theme.css | 476 ---- .../default/content/bootstrap-theme.css.map | 1 - .../default/content/bootstrap-theme.min.css | 5 - src/MyWebLog/views/themes/default/footer.html | 10 - .../views/themes/default/index-content.html | 43 - src/MyWebLog/views/themes/default/index.html | 9 - src/MyWebLog/views/themes/default/layout.html | 48 - .../views/themes/default/page-content.html | 4 - src/MyWebLog/views/themes/default/page.html | 9 - .../views/themes/default/single-content.html | 67 - src/MyWebLog/views/themes/default/single.html | 9 - src/Settings.FSharpLint | 5 - src/admin-theme/_layout.liquid | 54 + src/admin-theme/category-edit.liquid | 54 + src/admin-theme/category-list-body.liquid | 46 + src/admin-theme/category-list.liquid | 16 + src/admin-theme/custom-feed-edit.liquid | 208 ++ src/admin-theme/dashboard.liquid | 51 + src/admin-theme/layout-bare.liquid | 5 + src/admin-theme/layout-partial.liquid | 10 + src/admin-theme/layout.liquid | 32 + src/admin-theme/log-on.liquid | 30 + src/admin-theme/page-edit.liquid | 105 + src/admin-theme/page-list.liquid | 70 + src/admin-theme/permalinks.liquid | 59 + src/admin-theme/post-edit.liquid | 157 ++ src/admin-theme/post-list.liquid | 100 + src/admin-theme/rss-settings.liquid | 113 + src/admin-theme/settings.liquid | 80 + src/admin-theme/tag-mapping-edit.liquid | 30 + src/admin-theme/tag-mapping-list-body.liquid | 34 + src/admin-theme/tag-mapping-list.liquid | 14 + src/admin-theme/upload-theme.liquid | 26 + src/admin-theme/user-edit.liquid | 64 + src/admin-theme/version.txt | 2 + src/admin-theme/wwwroot/admin.css | 87 + src/admin-theme/wwwroot/admin.js | 322 +++ .../wwwroot}/logo-dark.png | Bin .../wwwroot}/logo-light.png | Bin src/default-theme/index.liquid | 56 + src/default-theme/layout.liquid | 62 + src/default-theme/single-page.liquid | 4 + src/default-theme/single-post.liquid | 62 + src/default-theme/version.txt | 2 + src/default-theme/wwwroot/style.css | 22 + src/global.json | 6 - src/myWebLog.sln | 43 - 125 files changed, 10015 insertions(+), 4521 deletions(-) delete mode 100644 src/MyWebLog.App/App.fs delete mode 100644 src/MyWebLog.App/AppConfig.fs delete mode 100644 src/MyWebLog.App/AssemblyInfo.fs delete mode 100644 src/MyWebLog.App/Data/Category.fs delete mode 100644 src/MyWebLog.App/Data/DataConfig.fs delete mode 100644 src/MyWebLog.App/Data/Extensions.fs delete mode 100644 src/MyWebLog.App/Data/Page.fs delete mode 100644 src/MyWebLog.App/Data/Post.fs delete mode 100644 src/MyWebLog.App/Data/RethinkMyWebLogData.fs delete mode 100644 src/MyWebLog.App/Data/SetUp.fs delete mode 100644 src/MyWebLog.App/Data/Table.fs delete mode 100644 src/MyWebLog.App/Data/User.fs delete mode 100644 src/MyWebLog.App/Data/WebLog.fs delete mode 100644 src/MyWebLog.App/Entities/Entities.fs delete mode 100644 src/MyWebLog.App/Entities/IMyWebLogData.fs delete mode 100644 src/MyWebLog.App/Keys.fs delete mode 100644 src/MyWebLog.App/Logic/Category.fs delete mode 100644 src/MyWebLog.App/Logic/Page.fs delete mode 100644 src/MyWebLog.App/Logic/Post.fs delete mode 100644 src/MyWebLog.App/Logic/User.fs delete mode 100644 src/MyWebLog.App/Logic/WebLog.fs delete mode 100644 src/MyWebLog.App/Modules/AdminModule.fs delete mode 100644 src/MyWebLog.App/Modules/CategoryModule.fs delete mode 100644 src/MyWebLog.App/Modules/ModuleExtensions.fs delete mode 100644 src/MyWebLog.App/Modules/PageModule.fs delete mode 100644 src/MyWebLog.App/Modules/PostModule.fs delete mode 100644 src/MyWebLog.App/Modules/UserModule.fs delete mode 100644 src/MyWebLog.App/MyWebLog.App.xproj delete mode 100644 src/MyWebLog.App/Strings.fs delete mode 100644 src/MyWebLog.App/ViewModels.fs delete mode 100644 src/MyWebLog.App/en-US.json delete mode 100644 src/MyWebLog.App/project.json create mode 100644 src/MyWebLog.Data/Converters.fs create mode 100644 src/MyWebLog.Data/Interfaces.fs create mode 100644 src/MyWebLog.Data/MyWebLog.Data.fsproj create mode 100644 src/MyWebLog.Data/RethinkDbData.fs create mode 100644 src/MyWebLog.Data/SQLiteData.fs create mode 100644 src/MyWebLog.Data/Utils.fs create mode 100644 src/MyWebLog.Domain/DataTypes.fs create mode 100644 src/MyWebLog.Domain/MyWebLog.Domain.fsproj create mode 100644 src/MyWebLog.Domain/SupportTypes.fs create mode 100644 src/MyWebLog.Domain/ViewModels.fs delete mode 100644 src/MyWebLog.Migrate/Program.fs delete mode 100644 src/MyWebLog.Migrate/project.json delete mode 100644 src/MyWebLog.Tests/MyWebLog.Tests.fs delete mode 100644 src/MyWebLog.Tests/MyWebLog.Tests.fsproj create mode 100644 src/MyWebLog.sln create mode 100644 src/MyWebLog/Caches.fs create mode 100644 src/MyWebLog/DotLiquidBespoke.fs create mode 100644 src/MyWebLog/Handlers/Admin.fs create mode 100644 src/MyWebLog/Handlers/Error.fs create mode 100644 src/MyWebLog/Handlers/Feed.fs create mode 100644 src/MyWebLog/Handlers/Helpers.fs create mode 100644 src/MyWebLog/Handlers/Post.fs create mode 100644 src/MyWebLog/Handlers/Routes.fs create mode 100644 src/MyWebLog/Handlers/User.fs create mode 100644 src/MyWebLog/Maintenance.fs create mode 100644 src/MyWebLog/MyWebLog.fsproj delete mode 100644 src/MyWebLog/MyWebLog.xproj delete mode 100644 src/MyWebLog/Program.cs create mode 100644 src/MyWebLog/Program.fs delete mode 100644 src/MyWebLog/Properties/AssemblyInfo.cs create mode 100644 src/MyWebLog/appsettings.json delete mode 100644 src/MyWebLog/config.json delete mode 100644 src/MyWebLog/project.json delete mode 100644 src/MyWebLog/views/admin/admin-layout.html delete mode 100644 src/MyWebLog/views/admin/category/edit.html delete mode 100644 src/MyWebLog/views/admin/category/list.html delete mode 100644 src/MyWebLog/views/admin/content/admin.css delete mode 100644 src/MyWebLog/views/admin/content/tinymce-init.js delete mode 100644 src/MyWebLog/views/admin/dashboard.html delete mode 100644 src/MyWebLog/views/admin/page/edit.html delete mode 100644 src/MyWebLog/views/admin/page/list.html delete mode 100644 src/MyWebLog/views/admin/post/edit.html delete mode 100644 src/MyWebLog/views/admin/post/list.html delete mode 100644 src/MyWebLog/views/admin/user/log-on.html delete mode 100644 src/MyWebLog/views/themes/default/comment.html delete mode 100644 src/MyWebLog/views/themes/default/content/bootstrap-theme.css delete mode 100644 src/MyWebLog/views/themes/default/content/bootstrap-theme.css.map delete mode 100644 src/MyWebLog/views/themes/default/content/bootstrap-theme.min.css delete mode 100644 src/MyWebLog/views/themes/default/footer.html delete mode 100644 src/MyWebLog/views/themes/default/index-content.html delete mode 100644 src/MyWebLog/views/themes/default/index.html delete mode 100644 src/MyWebLog/views/themes/default/layout.html delete mode 100644 src/MyWebLog/views/themes/default/page-content.html delete mode 100644 src/MyWebLog/views/themes/default/page.html delete mode 100644 src/MyWebLog/views/themes/default/single-content.html delete mode 100644 src/MyWebLog/views/themes/default/single.html delete mode 100644 src/Settings.FSharpLint create mode 100644 src/admin-theme/_layout.liquid create mode 100644 src/admin-theme/category-edit.liquid create mode 100644 src/admin-theme/category-list-body.liquid create mode 100644 src/admin-theme/category-list.liquid create mode 100644 src/admin-theme/custom-feed-edit.liquid create mode 100644 src/admin-theme/dashboard.liquid create mode 100644 src/admin-theme/layout-bare.liquid create mode 100644 src/admin-theme/layout-partial.liquid create mode 100644 src/admin-theme/layout.liquid create mode 100644 src/admin-theme/log-on.liquid create mode 100644 src/admin-theme/page-edit.liquid create mode 100644 src/admin-theme/page-list.liquid create mode 100644 src/admin-theme/permalinks.liquid create mode 100644 src/admin-theme/post-edit.liquid create mode 100644 src/admin-theme/post-list.liquid create mode 100644 src/admin-theme/rss-settings.liquid create mode 100644 src/admin-theme/settings.liquid create mode 100644 src/admin-theme/tag-mapping-edit.liquid create mode 100644 src/admin-theme/tag-mapping-list-body.liquid create mode 100644 src/admin-theme/tag-mapping-list.liquid create mode 100644 src/admin-theme/upload-theme.liquid create mode 100644 src/admin-theme/user-edit.liquid create mode 100644 src/admin-theme/version.txt create mode 100644 src/admin-theme/wwwroot/admin.css create mode 100644 src/admin-theme/wwwroot/admin.js rename src/{MyWebLog/content => admin-theme/wwwroot}/logo-dark.png (100%) rename src/{MyWebLog/content => admin-theme/wwwroot}/logo-light.png (100%) create mode 100644 src/default-theme/index.liquid create mode 100644 src/default-theme/layout.liquid create mode 100644 src/default-theme/single-page.liquid create mode 100644 src/default-theme/single-post.liquid create mode 100644 src/default-theme/version.txt create mode 100644 src/default-theme/wwwroot/style.css delete mode 100644 src/global.json delete mode 100644 src/myWebLog.sln diff --git a/.gitignore b/.gitignore index 51a0960..349e76c 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/src/MyWebLog.App/App.fs b/src/MyWebLog.App/App.fs deleted file mode 100644 index 96a3a13..0000000 --- a/src/MyWebLog.App/App.fs +++ /dev/null @@ -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\.(?[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() - |> 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 cfg - |> ignore - data.Force().SetUp () - container.Register (data.Force ()) - |> ignore - // NodaTime - container.Register SystemClock.Instance - |> ignore - // I18N in SSVE - container.Register (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 ()) - 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.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 diff --git a/src/MyWebLog.App/AppConfig.fs b/src/MyWebLog.App/AppConfig.fs deleted file mode 100644 index 915a18e..0000000 --- a/src/MyWebLog.App/AppConfig.fs +++ /dev/null @@ -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 - [] - PasswordSaltString : string - /// The text from which to derive salt to use for forms authentication - [] - AuthSaltString : string - /// The encryption passphrase to use for forms authentication - [] - AuthEncryptionPassphrase : string - /// The HMAC passphrase to use for forms authentication - [] - AuthHmacPassphrase : string - /// The data configuration - [] - 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 json - { cfg with DataConfig = DataConfig.Connect cfg.DataConfig } \ No newline at end of file diff --git a/src/MyWebLog.App/AssemblyInfo.fs b/src/MyWebLog.App/AssemblyInfo.fs deleted file mode 100644 index 0fb972b..0000000 --- a/src/MyWebLog.App/AssemblyInfo.fs +++ /dev/null @@ -1,21 +0,0 @@ -namespace MyWebLog.AssemblyInfo - -open System.Reflection -open System.Runtime.CompilerServices -open System.Runtime.InteropServices - -[] -[] -[] -[] -[] -[] -[] -[] -[] -[] -[] -[] - -do - () \ No newline at end of file diff --git a/src/MyWebLog.App/Data/Category.fs b/src/MyWebLog.App/Data/Category.fs deleted file mode 100644 index 94c13cc..0000000 --- a/src/MyWebLog.App/Data/Category.fs +++ /dev/null @@ -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 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 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 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 conn - return cat |> List.tryHead - } - |> Async.RunSynchronously diff --git a/src/MyWebLog.App/Data/DataConfig.fs b/src/MyWebLog.App/Data/DataConfig.fs deleted file mode 100644 index 0d993c2..0000000 --- a/src/MyWebLog.App/Data/DataConfig.fs +++ /dev/null @@ -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 - [] - Hostname : string - /// The port for the RethinkDB server - [] - Port : int - /// The authorization key to use when connecting to the server - [] - AuthKey : string - /// How long an attempt to connect to the server should wait before giving up - [] - Timeout : int - /// The name of the default database to use on the connection - [] - Database : string - /// A connection to the RethinkDB server using the configuration in this object - [] - 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 diff --git a/src/MyWebLog.App/Data/Extensions.fs b/src/MyWebLog.App/Data/Extensions.fs deleted file mode 100644 index 82c7645..0000000 --- a/src/MyWebLog.App/Data/Extensions.fs +++ /dev/null @@ -1,16 +0,0 @@ -[] -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 diff --git a/src/MyWebLog.App/Data/Page.fs b/src/MyWebLog.App/Data/Page.fs deleted file mode 100644 index 34db086..0000000 --- a/src/MyWebLog.App/Data/Page.fs +++ /dev/null @@ -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 conn - | _ -> q.Without("Revisions").RunResultAsync 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 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 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) - | _ -> () diff --git a/src/MyWebLog.App/Data/Post.fs b/src/MyWebLog.App/Data/Post.fs deleted file mode 100644 index 4d4bb14..0000000 --- a/src/MyWebLog.App/Data/Post.fs +++ /dev/null @@ -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 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 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 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 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 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 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 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 diff --git a/src/MyWebLog.App/Data/RethinkMyWebLogData.fs b/src/MyWebLog.App/Data/RethinkMyWebLogData.fs deleted file mode 100644 index a7cf789..0000000 --- a/src/MyWebLog.App/Data/RethinkMyWebLogData.fs +++ /dev/null @@ -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 - \ No newline at end of file diff --git a/src/MyWebLog.App/Data/SetUp.fs b/src/MyWebLog.App/Data/SetUp.fs deleted file mode 100644 index bd0341a..0000000 --- a/src/MyWebLog.App/Data/SetUp.fs +++ /dev/null @@ -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 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 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 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 diff --git a/src/MyWebLog.App/Data/Table.fs b/src/MyWebLog.App/Data/Table.fs deleted file mode 100644 index 8a84652..0000000 --- a/src/MyWebLog.App/Data/Table.fs +++ /dev/null @@ -1,21 +0,0 @@ -/// Constants for tables used in myWebLog -[] -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" \ No newline at end of file diff --git a/src/MyWebLog.App/Data/User.fs b/src/MyWebLog.App/Data/User.fs deleted file mode 100644 index 0e4cecf..0000000 --- a/src/MyWebLog.App/Data/User.fs +++ /dev/null @@ -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 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 \ No newline at end of file diff --git a/src/MyWebLog.App/Data/WebLog.fs b/src/MyWebLog.App/Data/WebLog.fs deleted file mode 100644 index 6d236f8..0000000 --- a/src/MyWebLog.App/Data/WebLog.fs +++ /dev/null @@ -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 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 conn - } - |> Async.RunSynchronously - \ No newline at end of file diff --git a/src/MyWebLog.App/Entities/Entities.fs b/src/MyWebLog.App/Entities/Entities.fs deleted file mode 100644 index b35a111..0000000 --- a/src/MyWebLog.App/Entities/Entities.fs +++ /dev/null @@ -1,301 +0,0 @@ -namespace MyWebLog.Entities - -open Newtonsoft.Json - -// --- Constants --- - -/// Constants to use for revision source language -[] -module RevisionSource = - [] - let Markdown = "markdown" - [] - let HTML = "html" - -/// Constants to use for authorization levels -[] -module AuthorizationLevel = - [] - let Administrator = "Administrator" - [] - let User = "User" - -/// Constants to use for post statuses -[] -module PostStatus = - [] - let Draft = "Draft" - [] - let Published = "Published" - -/// Constants to use for comment statuses -[] -module CommentStatus = - [] - let Approved = "Approved" - [] - let Pending = "Pending" - [] - 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 - [] - 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 - [] - 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 - [] - 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 - [] - 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 - [] - 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 - [] - 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 - [] - 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 } diff --git a/src/MyWebLog.App/Entities/IMyWebLogData.fs b/src/MyWebLog.App/Entities/IMyWebLogData.fs deleted file mode 100644 index 2972f79..0000000 --- a/src/MyWebLog.App/Entities/IMyWebLogData.fs +++ /dev/null @@ -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) diff --git a/src/MyWebLog.App/Keys.fs b/src/MyWebLog.App/Keys.fs deleted file mode 100644 index 741b6b4..0000000 --- a/src/MyWebLog.App/Keys.fs +++ /dev/null @@ -1,17 +0,0 @@ -[] -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" \ No newline at end of file diff --git a/src/MyWebLog.App/Logic/Category.fs b/src/MyWebLog.App/Logic/Category.fs deleted file mode 100644 index e91e771..0000000 --- a/src/MyWebLog.App/Logic/Category.fs +++ /dev/null @@ -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 diff --git a/src/MyWebLog.App/Logic/Page.fs b/src/MyWebLog.App/Logic/Page.fs deleted file mode 100644 index 81fb0f4..0000000 --- a/src/MyWebLog.App/Logic/Page.fs +++ /dev/null @@ -1,29 +0,0 @@ -/// Logic for manipulating 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 diff --git a/src/MyWebLog.App/Logic/Post.fs b/src/MyWebLog.App/Logic/Post.fs deleted file mode 100644 index 1918cbf..0000000 --- a/src/MyWebLog.App/Logic/Post.fs +++ /dev/null @@ -1,60 +0,0 @@ -/// Logic for manipulating 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 diff --git a/src/MyWebLog.App/Logic/User.fs b/src/MyWebLog.App/Logic/User.fs deleted file mode 100644 index 4c6c1dc..0000000 --- a/src/MyWebLog.App/Logic/User.fs +++ /dev/null @@ -1,9 +0,0 @@ -/// Logic for manipulating 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 \ No newline at end of file diff --git a/src/MyWebLog.App/Logic/WebLog.fs b/src/MyWebLog.App/Logic/WebLog.fs deleted file mode 100644 index a1dfc70..0000000 --- a/src/MyWebLog.App/Logic/WebLog.fs +++ /dev/null @@ -1,11 +0,0 @@ -/// Logic for manipulating 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 diff --git a/src/MyWebLog.App/Modules/AdminModule.fs b/src/MyWebLog.App/Modules/AdminModule.fs deleted file mode 100644 index de094f0..0000000 --- a/src/MyWebLog.App/Modules/AdminModule.fs +++ /dev/null @@ -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] diff --git a/src/MyWebLog.App/Modules/CategoryModule.fs b/src/MyWebLog.App/Modules/CategoryModule.fs deleted file mode 100644 index 1d752eb..0000000 --- a/src/MyWebLog.App/Modules/CategoryModule.fs +++ /dev/null @@ -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 () - 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 () diff --git a/src/MyWebLog.App/Modules/ModuleExtensions.fs b/src/MyWebLog.App/Modules/ModuleExtensions.fs deleted file mode 100644 index dd3d069..0000000 --- a/src/MyWebLog.App/Modules/ModuleExtensions.fs +++ /dev/null @@ -1,36 +0,0 @@ -[] -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 (fun claim -> - claim.Type = ClaimTypes.Role && claim.Value = sprintf "%s|%s" this.WebLog.Id level) - this.RequiresAuthentication () - this.RequiresClaims [| findClaim |] diff --git a/src/MyWebLog.App/Modules/PageModule.fs b/src/MyWebLog.App/Modules/PageModule.fs deleted file mode 100644 index a928dac..0000000 --- a/src/MyWebLog.App/Modules/PageModule.fs +++ /dev/null @@ -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 () - 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 () diff --git a/src/MyWebLog.App/Modules/PostModule.fs b/src/MyWebLog.App/Modules/PostModule.fs deleted file mode 100644 index ec29d85..0000000 --- a/src/MyWebLog.App/Modules/PostModule.fs +++ /dev/null @@ -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 () - 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(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 () diff --git a/src/MyWebLog.App/Modules/UserModule.fs b/src/MyWebLog.App/Modules/UserModule.fs deleted file mode 100644 index eca46af..0000000 --- a/src/MyWebLog.App/Modules/UserModule.fs +++ /dev/null @@ -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 () - 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 "/" diff --git a/src/MyWebLog.App/MyWebLog.App.xproj b/src/MyWebLog.App/MyWebLog.App.xproj deleted file mode 100644 index eb039d1..0000000 --- a/src/MyWebLog.App/MyWebLog.App.xproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - 14.0 - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - - - - - 9cea3a8b-e8aa-44e6-9f5f-2095ceed54eb - Nancy.Session.Persistable - .\obj - .\bin\ - v4.5.2 - - - - 2.0 - - - diff --git a/src/MyWebLog.App/Strings.fs b/src/MyWebLog.App/Strings.fs deleted file mode 100644 index f207444..0000000 --- a/src/MyWebLog.App/Strings.fs +++ /dev/null @@ -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.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>(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 diff --git a/src/MyWebLog.App/ViewModels.fs b/src/MyWebLog.App/ViewModels.fs deleted file mode 100644 index 7a0babe..0000000 --- a/src/MyWebLog.App/ViewModels.fs +++ /dev/null @@ -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 -[] -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 - [] - member this.ToDisplay = - let classAndLabel = - dict [ - Level.Error, ("danger", Strings.get "Error") - Level.Warning, ("warning", Strings.get "Warning") - Level.Info, ("info", "") - ] - seq { - yield "
" - match snd classAndLabel.[this.Level] with - | "" -> () - | lbl -> yield lbl.ToUpper () - yield " » " - yield this.Message - yield "" - match this.Details with - | Some d -> yield "
" - yield d - | None -> () - yield "
" - } - |> 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 (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 "\"myWebLog\"" - } - |> 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 (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 "" - } - |> 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 "%s" 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 diff --git a/src/MyWebLog.App/en-US.json b/src/MyWebLog.App/en-US.json deleted file mode 100644 index be2715a..0000000 --- a/src/MyWebLog.App/en-US.json +++ /dev/null @@ -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" -} diff --git a/src/MyWebLog.App/project.json b/src/MyWebLog.App/project.json deleted file mode 100644 index 3ed4776..0000000 --- a/src/MyWebLog.App/project.json +++ /dev/null @@ -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" -} diff --git a/src/MyWebLog.Data/Converters.fs b/src/MyWebLog.Data/Converters.fs new file mode 100644 index 0000000..d0ad6d5 --- /dev/null +++ b/src/MyWebLog.Data/Converters.fs @@ -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 () + 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 () + 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 () + 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 () + 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 () + 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 () + 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 () + 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 () + 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 () + 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 () + 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 () + 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 () + 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 () + 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 () + 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 () + } diff --git a/src/MyWebLog.Data/Interfaces.fs b/src/MyWebLog.Data/Interfaces.fs new file mode 100644 index 0000000..bb0bd1d --- /dev/null +++ b/src/MyWebLog.Data/Interfaces.fs @@ -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 + + /// Count all categories for the given web log + abstract member countAll : WebLogId -> Task + + /// Count all top-level categories for the given web log + abstract member countTopLevel : WebLogId -> Task + + /// Delete a category (also removes it from posts) + abstract member delete : CategoryId -> WebLogId -> Task + + /// Find all categories for a web log, sorted alphabetically and grouped by hierarchy + abstract member findAllForView : WebLogId -> Task + + /// Find a category by its ID + abstract member findById : CategoryId -> WebLogId -> Task + + /// Find all categories for the given web log + abstract member findByWebLog : WebLogId -> Task + + /// Restore categories from a backup + abstract member restore : Category list -> Task + + /// Update a category (slug, name, description, and parent ID) + abstract member update : Category -> Task + + +/// Data functions to support manipulating pages +type IPageData = + + /// Add a page + abstract member add : Page -> Task + + /// Get all pages for the web log (excluding meta items, text, revisions, and prior permalinks) + abstract member all : WebLogId -> Task + + /// Count all pages for the given web log + abstract member countAll : WebLogId -> Task + + /// Count pages marked as "show in page list" for the given web log + abstract member countListed : WebLogId -> Task + + /// Delete a page + abstract member delete : PageId -> WebLogId -> Task + + /// Find a page by its ID (excluding revisions and prior permalinks) + abstract member findById : PageId -> WebLogId -> Task + + /// Find a page by its permalink (excluding revisions and prior permalinks) + abstract member findByPermalink : Permalink -> WebLogId -> Task + + /// Find the current permalink for a page from a list of prior permalinks + abstract member findCurrentPermalink : Permalink list -> WebLogId -> Task + + /// Find a page by its ID (including revisions and prior permalinks) + abstract member findFullById : PageId -> WebLogId -> Task + + /// Find all pages for the given web log (including revisions and prior permalinks) + abstract member findFullByWebLog : WebLogId -> Task + + /// Find pages marked as "show in page list" for the given web log (excluding text, revisions, and prior permalinks) + abstract member findListed : WebLogId -> Task + + /// Find a page of pages (displayed in admin section) (excluding meta items, revisions and prior permalinks) + abstract member findPageOfPages : WebLogId -> pageNbr : int -> Task + + /// Restore pages from a backup + abstract member restore : Page list -> Task + + /// Update a page + abstract member update : Page -> Task + + /// Update the prior permalinks for the given page + abstract member updatePriorPermalinks : PageId -> WebLogId -> Permalink list -> Task + + +/// Data functions to support manipulating posts +type IPostData = + + /// Add a post + abstract member add : Post -> Task + + /// Count posts by their status + abstract member countByStatus : PostStatus -> WebLogId -> Task + + /// Delete a post + abstract member delete : PostId -> WebLogId -> Task + + /// Find a post by its permalink (excluding revisions and prior permalinks) + abstract member findByPermalink : Permalink -> WebLogId -> Task + + /// Find the current permalink for a post from a list of prior permalinks + abstract member findCurrentPermalink : Permalink list -> WebLogId -> Task + + /// Find a post by its ID (including revisions and prior permalinks) + abstract member findFullById : PostId -> WebLogId -> Task + + /// Find all posts for the given web log (including revisions and prior permalinks) + abstract member findFullByWebLog : WebLogId -> Task + + /// 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 + + /// Find posts to be displayed on an admin page (excluding revisions and prior permalinks) + abstract member findPageOfPosts : WebLogId -> pageNbr : int -> postsPerPage : int -> Task + + /// Find posts to be displayed on a page (excluding revisions and prior permalinks) + abstract member findPageOfPublishedPosts : WebLogId -> pageNbr : int -> postsPerPage : int -> Task + + /// 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 + + /// 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 + + /// Restore posts from a backup + abstract member restore : Post list -> Task + + /// Update a post + abstract member update : Post -> Task + + /// Update the prior permalinks for a post + abstract member updatePriorPermalinks : PostId -> WebLogId -> Permalink list -> Task + + +/// Functions to manipulate tag mappings +type ITagMapData = + + /// Delete a tag mapping + abstract member delete : TagMapId -> WebLogId -> Task + + /// Find a tag mapping by its ID + abstract member findById : TagMapId -> WebLogId -> Task + + /// Find a tag mapping by its URL value + abstract member findByUrlValue : string -> WebLogId -> Task + + /// Retrieve all tag mappings for the given web log + abstract member findByWebLog : WebLogId -> Task + + /// Find tag mappings for the given tags + abstract member findMappingForTags : tags : string list -> WebLogId -> Task + + /// Restore tag mappings from a backup + abstract member restore : TagMap list -> Task + + /// Save a tag mapping (insert or update) + abstract member save : TagMap -> Task + + +/// Functions to manipulate themes +type IThemeData = + + /// Retrieve all themes (except "admin") + abstract member all : unit -> Task + + /// Find a theme by its ID + abstract member findById : ThemeId -> Task + + /// Find a theme by its ID (excluding the text of its templates) + abstract member findByIdWithoutText : ThemeId -> Task + + /// Save a theme (insert or update) + abstract member save : Theme -> Task + + +/// Functions to manipulate theme assets +type IThemeAssetData = + + /// Retrieve all theme assets (excluding data) + abstract member all : unit -> Task + + /// Delete all theme assets for the given theme + abstract member deleteByTheme : ThemeId -> Task + + /// Find a theme asset by its ID + abstract member findById : ThemeAssetId -> Task + + /// Find all assets for the given theme (excludes data) + abstract member findByTheme : ThemeId -> Task + + /// Find all assets for the given theme (includes data) + abstract member findByThemeWithData : ThemeId -> Task + + /// Save a theme asset (insert or update) + abstract member save : ThemeAsset -> Task + + +/// Functions to manipulate web logs +type IWebLogData = + + /// Add a web log + abstract member add : WebLog -> Task + + /// Retrieve all web logs + abstract member all : unit -> Task + + /// Delete a web log, including categories, tag mappings, posts/comments, and pages + abstract member delete : WebLogId -> Task + + /// Find a web log by its host (URL base) + abstract member findByHost : string -> Task + + /// Find a web log by its ID + abstract member findById : WebLogId -> Task + + /// Update RSS options for a web log + abstract member updateRssOptions : WebLog -> Task + + /// Update web log settings (from the settings page) + abstract member updateSettings : WebLog -> Task + + +/// Functions to manipulate web log users +type IWebLogUserData = + + /// Add a web log user + abstract member add : WebLogUser -> Task + + /// Find a web log user by their e-mail address + abstract member findByEmail : email : string -> WebLogId -> Task + + /// Find a web log user by their ID + abstract member findById : WebLogUserId -> WebLogId -> Task + + /// Find all web log users for the given web log + abstract member findByWebLog : WebLogId -> Task + + /// Get a user ID -> name dictionary for the given user IDs + abstract member findNames : WebLogId -> WebLogUserId list -> Task + + /// Restore users from a backup + abstract member restore : WebLogUser list -> Task + + /// Update a web log user + abstract member update : WebLogUser -> Task + + +/// 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 + \ No newline at end of file diff --git a/src/MyWebLog.Data/MyWebLog.Data.fsproj b/src/MyWebLog.Data/MyWebLog.Data.fsproj new file mode 100644 index 0000000..3935956 --- /dev/null +++ b/src/MyWebLog.Data/MyWebLog.Data.fsproj @@ -0,0 +1,30 @@ + + + + net6.0 + true + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MyWebLog.Data/RethinkDbData.fs b/src/MyWebLog.Data/RethinkDbData.fs new file mode 100644 index 0000000..4357247 --- /dev/null +++ b/src/MyWebLog.Data/RethinkDbData.fs @@ -0,0 +1,885 @@ +namespace MyWebLog.Data + +open System.Threading.Tasks +open MyWebLog +open RethinkDb.Driver + +/// Functions to assist with retrieving data +[] +module private RethinkHelpers = + + /// Table names + [] + 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) = + + /// 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 { 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 { + withTable Table.Category + getAll [ webLogId ] (nameof webLogId) + count + result; withRetryDefault conn + } + + member _.countTopLevel webLogId = rethink { + withTable Table.Category + getAll [ webLogId ] (nameof webLogId) + filter "parentId" None + count + result; withRetryDefault conn + } + + member _.findAllForView webLogId = backgroundTask { + let! cats = rethink { + 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 { + 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 { + withTable Table.Category + get catId + resultOption; withRetryOptionDefault + } + |> verifyWebLog webLogId (fun c -> c.webLogId) <| conn + + member _.findByWebLog webLogId = rethink { + 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 { + 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 { + withTable Table.Page + getAll [ webLogId ] (nameof webLogId) + count + result; withRetryDefault conn + } + + member _.countListed webLogId = rethink { + withTable Table.Page + getAll [ webLogId ] (nameof webLogId) + filter "showInPageList" true + count + result; withRetryDefault conn + } + + member _.delete pageId webLogId = backgroundTask { + let! result = rethink { + 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 { + withTable Table.Page + get pageId + without [ "priorPermalinks"; "revisions" ] + resultOption; withRetryOptionDefault + } + |> verifyWebLog webLogId (fun it -> it.webLogId) <| conn + + member _.findByPermalink permalink webLogId = + rethink { + 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 { + 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 { + withTable Table.Page + get pageId + resultOption; withRetryOptionDefault + } + |> verifyWebLog webLogId (fun it -> it.webLogId) <| conn + + member _.findFullByWebLog webLogId = rethink { + withTable Table.Page + getAll [ webLogId ] (nameof webLogId) + resultCursor; withRetryCursorDefault; toList conn + } + + member _.findListed webLogId = rethink { + 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 { + 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 { + withTable Table.Post + getAll [ webLogId ] (nameof webLogId) + filter "status" status + count + result; withRetryDefault conn + } + + member _.delete postId webLogId = backgroundTask { + let! result = rethink { + 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 { + withTable Table.Post + getAll [ r.Array (webLogId, permalink) ] (nameof permalink) + without [ "priorPermalinks"; "revisions" ] + limit 1 + result; withRetryDefault + } + |> tryFirst <| conn + + member _.findFullById postId webLogId = + rethink { + withTable Table.Post + get postId + resultOption; withRetryOptionDefault + } + |> verifyWebLog webLogId (fun p -> p.webLogId) <| conn + + member _.findCurrentPermalink permalinks webLogId = backgroundTask { + let! result = + (rethink { + 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 { + withTable Table.Post + getAll [ webLogId ] (nameof webLogId) + resultCursor; withRetryCursorDefault; toList conn + } + + member _.findPageOfCategorizedPosts webLogId categoryIds pageNbr postsPerPage = rethink { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + withTable Table.TagMap + get tagMapId + resultOption; withRetryOptionDefault + } + |> verifyWebLog webLogId (fun tm -> tm.webLogId) <| conn + + member _.findByUrlValue urlValue webLogId = + rethink { + withTable Table.TagMap + getAll [ r.Array (webLogId, urlValue) ] "webLogAndUrl" + limit 1 + result; withRetryDefault + } + |> tryFirst <| conn + + member _.findByWebLog webLogId = rethink { + 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 { + 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 { + withTable Table.Theme + filter (fun row -> row["id"].Ne "admin" :> obj) + without [ "templates" ] + orderBy "id" + result; withRetryDefault conn + } + + member _.findById themeId = rethink { + withTable Table.Theme + get themeId + resultOption; withRetryOptionDefault conn + } + + member _.findByIdWithoutText themeId = rethink { + 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 { + 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 { + withTable Table.ThemeAsset + get assetId + resultOption; withRetryOptionDefault conn + } + + member _.findByTheme themeId = rethink { + withTable Table.ThemeAsset + filter (matchAssetByThemeId themeId) + without [ "data" ] + result; withRetryDefault conn + } + + member _.findByThemeWithData themeId = rethink { + 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 { + 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 { + withTable Table.WebLog + getAll [ url ] "urlBase" + limit 1 + result; withRetryDefault + } + |> tryFirst <| conn + + member _.findById webLogId = rethink { + 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 { + withTable Table.WebLogUser + getAll [ r.Array (webLogId, email) ] "logOn" + limit 1 + result; withRetryDefault + } + |> tryFirst <| conn + + member _.findById userId webLogId = + rethink { + withTable Table.WebLogUser + get userId + resultOption; withRetryOptionDefault + } + |> verifyWebLog webLogId (fun u -> u.webLogId) <| conn + + member _.findByWebLog webLogId = rethink { + withTable Table.WebLogUser + getAll [ webLogId ] (nameof webLogId) + result; withRetryDefault conn + } + + member _.findNames webLogId userIds = backgroundTask { + let! users = rethink { + 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 { 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 { 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" ] + } diff --git a/src/MyWebLog.Data/SQLiteData.fs b/src/MyWebLog.Data/SQLiteData.fs new file mode 100644 index 0000000..67433e0 --- /dev/null +++ b/src/MyWebLog.Data/SQLiteData.fs @@ -0,0 +1,2017 @@ +namespace MyWebLog.Data + +open System +open System.IO +open System.Threading.Tasks +open Microsoft.Data.Sqlite +open Microsoft.Extensions.Logging +open MyWebLog +open MyWebLog.ViewModels + +/// Helper functions for the SQLite data implementation +[] +module private SqliteHelpers = + + /// Run a command that returns a count + let count (cmd : SqliteCommand) = backgroundTask { + let! it = cmd.ExecuteScalarAsync () + return int (it :?> int64) + } + + /// Get lists of items removed from and added to the given lists + let diffLists<'T, 'U when 'U : equality> oldItems newItems (f : 'T -> 'U) = + let diff compList = fun item -> not (compList |> List.exists (fun other -> f item = f other)) + List.filter (diff newItems) oldItems, List.filter (diff oldItems) newItems + + /// Create a list of items from the given data reader + let toList<'T> (it : SqliteDataReader -> 'T) (rdr : SqliteDataReader) = + seq { while rdr.Read () do it rdr } + |> List.ofSeq + + /// Verify that the web log ID matches before returning an item + let verifyWebLog<'T> webLogId (prop : 'T -> WebLogId) (it : SqliteDataReader -> 'T) (rdr : SqliteDataReader) = + if rdr.Read () then + let item = it rdr + if prop item = webLogId then Some item else None + else + None + + /// Execute a command that returns no data + let write (cmd : SqliteCommand) = backgroundTask { + let! _ = cmd.ExecuteNonQueryAsync () + () + } + + /// Functions to map domain items from a data reader + module Map = + + /// Get a boolean value from a data reader + let getBoolean col (rdr : SqliteDataReader) = rdr.GetBoolean (rdr.GetOrdinal col) + + /// Get a date/time value from a data reader + let getDateTime col (rdr : SqliteDataReader) = rdr.GetDateTime (rdr.GetOrdinal col) + + /// Get a Guid value from a data reader + let getGuid col (rdr : SqliteDataReader) = rdr.GetGuid (rdr.GetOrdinal col) + + /// Get an int value from a data reader + let getInt col (rdr : SqliteDataReader) = rdr.GetInt32 (rdr.GetOrdinal col) + + /// Get a BLOB stream value from a data reader + let getStream col (rdr : SqliteDataReader) = rdr.GetStream (rdr.GetOrdinal col) + + /// Get a string value from a data reader + let getString col (rdr : SqliteDataReader) = rdr.GetString (rdr.GetOrdinal col) + + /// Get a possibly null date/time value from a data reader + let tryDateTime col (rdr : SqliteDataReader) = + if rdr.IsDBNull (rdr.GetOrdinal col) then None else Some (getDateTime col rdr) + + /// Get a possibly null int value from a data reader + let tryInt col (rdr : SqliteDataReader) = + if rdr.IsDBNull (rdr.GetOrdinal col) then None else Some (getInt col rdr) + + /// Get a possibly null string value from a data reader + let tryString col (rdr : SqliteDataReader) = + if rdr.IsDBNull (rdr.GetOrdinal col) then None else Some (getString col rdr) + + /// Create a category ID from the current row in the given data reader + let toCategoryId = getString "id" >> CategoryId + + /// Create a category from the current row in the given data reader + let toCategory (rdr : SqliteDataReader) : Category = + { id = toCategoryId rdr + webLogId = WebLogId (getString "web_log_id" rdr) + name = getString "name" rdr + slug = getString "slug" rdr + description = tryString "description" rdr + parentId = tryString "parent_id" rdr |> Option.map CategoryId + } + + /// Create a custom feed from the current row in the given data reader + let toCustomFeed (rdr : SqliteDataReader) : CustomFeed = + { id = CustomFeedId (getString "id" rdr) + source = CustomFeedSource.parse (getString "source" rdr) + path = Permalink (getString "path" rdr) + podcast = + if rdr.IsDBNull (rdr.GetOrdinal "title") then + None + else + Some { + title = getString "title" rdr + subtitle = tryString "subtitle" rdr + itemsInFeed = getInt "items_in_feed" rdr + summary = getString "summary" rdr + displayedAuthor = getString "displayed_author" rdr + email = getString "email" rdr + imageUrl = Permalink (getString "image_url" rdr) + iTunesCategory = getString "itunes_category" rdr + iTunesSubcategory = tryString "itunes_subcategory" rdr + explicit = ExplicitRating.parse (getString "explicit" rdr) + defaultMediaType = tryString "default_media_type" rdr + mediaBaseUrl = tryString "media_base_url" rdr + } + } + + /// Create a meta item from the current row in the given data reader + let toMetaItem (rdr : SqliteDataReader) : MetaItem = + { name = getString "name" rdr + value = getString "value" rdr + } + + /// Create a permalink from the current row in the given data reader + let toPermalink = getString "permalink" >> Permalink + + /// Create a page from the current row in the given data reader + let toPage (rdr : SqliteDataReader) : Page = + { Page.empty with + id = PageId (getString "id" rdr) + webLogId = WebLogId (getString "web_log_id" rdr) + authorId = WebLogUserId (getString "author_id" rdr) + title = getString "title" rdr + permalink = toPermalink rdr + publishedOn = getDateTime "published_on" rdr + updatedOn = getDateTime "updated_on" rdr + showInPageList = getBoolean "show_in_page_list" rdr + template = tryString "template" rdr + text = getString "page_text" rdr + } + + /// Create a post from the current row in the given data reader + let toPost (rdr : SqliteDataReader) : Post = + { Post.empty with + id = PostId (getString "id" rdr) + webLogId = WebLogId (getString "web_log_id" rdr) + authorId = WebLogUserId (getString "author_id" rdr) + status = PostStatus.parse (getString "status" rdr) + title = getString "title" rdr + permalink = toPermalink rdr + publishedOn = tryDateTime "published_on" rdr + updatedOn = getDateTime "updated_on" rdr + template = tryString "template" rdr + text = getString "post_text" rdr + } + + /// Create a revision from the current row in the given data reader + let toRevision (rdr : SqliteDataReader) : Revision = + { asOf = getDateTime "as_of" rdr + text = MarkupText.parse (getString "revision_text" rdr) + } + + /// Create a tag mapping from the current row in the given data reader + let toTagMap (rdr : SqliteDataReader) : TagMap = + { id = TagMapId (getString "id" rdr) + webLogId = WebLogId (getString "web_log_id" rdr) + tag = getString "tag" rdr + urlValue = getString "url_value" rdr + } + + /// Create a theme from the current row in the given data reader (excludes templates) + let toTheme (rdr : SqliteDataReader) : Theme = + { Theme.empty with + id = ThemeId (getString "id" rdr) + name = getString "name" rdr + version = getString "version" rdr + } + + /// Create a theme asset from the current row in the given data reader + let toThemeAsset includeData (rdr : SqliteDataReader) : ThemeAsset = + let assetData = + if includeData then + use dataStream = new MemoryStream () + use blobStream = getStream "data" rdr + blobStream.CopyTo dataStream + dataStream.ToArray () + else + [||] + { id = ThemeAssetId (ThemeId (getString "theme_id" rdr), getString "path" rdr) + updatedOn = getDateTime "updated_on" rdr + data = assetData + } + + /// Create a theme template from the current row in the given data reader + let toThemeTemplate (rdr : SqliteDataReader) : ThemeTemplate = + { name = getString "name" rdr + text = getString "template" rdr + } + + /// Create a web log from the current row in the given data reader + let toWebLog (rdr : SqliteDataReader) : WebLog = + { id = WebLogId (getString "id" rdr) + name = getString "name" rdr + subtitle = tryString "subtitle" rdr + defaultPage = getString "default_page" rdr + postsPerPage = getInt "posts_per_page" rdr + themePath = getString "theme_id" rdr + urlBase = getString "url_base" rdr + timeZone = getString "time_zone" rdr + autoHtmx = getBoolean "auto_htmx" rdr + rss = { + feedEnabled = getBoolean "feed_enabled" rdr + feedName = getString "feed_name" rdr + itemsInFeed = tryInt "items_in_feed" rdr + categoryEnabled = getBoolean "category_enabled" rdr + tagEnabled = getBoolean "tag_enabled" rdr + copyright = tryString "copyright" rdr + customFeeds = [] + } + } + + /// Create a web log user from the current row in the given data reader + let toWebLogUser (rdr : SqliteDataReader) : WebLogUser = + { id = WebLogUserId (getString "id" rdr) + webLogId = WebLogId (getString "web_log_id" rdr) + userName = getString "user_name" rdr + firstName = getString "first_name" rdr + lastName = getString "last_name" rdr + preferredName = getString "preferred_name" rdr + passwordHash = getString "password_hash" rdr + salt = getGuid "salt" rdr + url = tryString "url" rdr + authorizationLevel = AuthorizationLevel.parse (getString "authorization_level" rdr) + } + + /// Add a possibly-missing parameter, substituting null for None + let maybe<'T> (it : 'T option) : obj = match it with Some x -> x :> obj | None -> DBNull.Value + + +/// SQLite myWebLog data implementation +type SQLiteData (conn : SqliteConnection, log : ILogger) = + + /// Add parameters for category INSERT or UPDATE statements + let addCategoryParameters (cmd : SqliteCommand) (cat : Category) = + [ cmd.Parameters.AddWithValue ("@id", CategoryId.toString cat.id) + cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString cat.webLogId) + cmd.Parameters.AddWithValue ("@name", cat.name) + cmd.Parameters.AddWithValue ("@slug", cat.slug) + cmd.Parameters.AddWithValue ("@description", maybe cat.description) + cmd.Parameters.AddWithValue ("@parentId", maybe (cat.parentId |> Option.map CategoryId.toString)) + ] |> ignore + + /// Add parameters for page INSERT or UPDATE statements + let addPageParameters (cmd : SqliteCommand) (page : Page) = + [ cmd.Parameters.AddWithValue ("@id", PageId.toString page.id) + cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString page.webLogId) + cmd.Parameters.AddWithValue ("@authorId", WebLogUserId.toString page.authorId) + cmd.Parameters.AddWithValue ("@title", page.title) + cmd.Parameters.AddWithValue ("@permalink", Permalink.toString page.permalink) + cmd.Parameters.AddWithValue ("@publishedOn", page.publishedOn) + cmd.Parameters.AddWithValue ("@updatedOn", page.updatedOn) + cmd.Parameters.AddWithValue ("@showInPageList", page.showInPageList) + cmd.Parameters.AddWithValue ("@template", maybe page.template) + cmd.Parameters.AddWithValue ("@text", page.text) + ] |> ignore + + /// Add parameters for post INSERT or UPDATE statements + let addPostParameters (cmd : SqliteCommand) (post : Post) = + [ cmd.Parameters.AddWithValue ("@id", PostId.toString post.id) + cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString post.webLogId) + cmd.Parameters.AddWithValue ("@authorId", WebLogUserId.toString post.authorId) + cmd.Parameters.AddWithValue ("@status", PostStatus.toString post.status) + cmd.Parameters.AddWithValue ("@title", post.title) + cmd.Parameters.AddWithValue ("@permalink", Permalink.toString post.permalink) + cmd.Parameters.AddWithValue ("@publishedOn", maybe post.publishedOn) + cmd.Parameters.AddWithValue ("@updatedOn", post.updatedOn) + cmd.Parameters.AddWithValue ("@template", maybe post.template) + cmd.Parameters.AddWithValue ("@text", post.text) + ] |> ignore + + /// Add parameters for web log INSERT or web log/RSS options UPDATE statements + let addWebLogRssParameters (cmd : SqliteCommand) (webLog : WebLog) = + [ cmd.Parameters.AddWithValue ("@feedEnabled", webLog.rss.feedEnabled) + cmd.Parameters.AddWithValue ("@feedName", webLog.rss.feedName) + cmd.Parameters.AddWithValue ("@itemsInFeed", maybe webLog.rss.itemsInFeed) + cmd.Parameters.AddWithValue ("@categoryEnabled", webLog.rss.categoryEnabled) + cmd.Parameters.AddWithValue ("@tagEnabled", webLog.rss.tagEnabled) + cmd.Parameters.AddWithValue ("@copyright", maybe webLog.rss.copyright) + ] |> ignore + + /// Add parameters for web log INSERT or UPDATE statements + let addWebLogParameters (cmd : SqliteCommand) (webLog : WebLog) = + [ cmd.Parameters.AddWithValue ("@id", WebLogId.toString webLog.id) + cmd.Parameters.AddWithValue ("@name", webLog.name) + cmd.Parameters.AddWithValue ("@subtitle", maybe webLog.subtitle) + cmd.Parameters.AddWithValue ("@defaultPage", webLog.defaultPage) + cmd.Parameters.AddWithValue ("@postsPerPage", webLog.postsPerPage) + cmd.Parameters.AddWithValue ("@themeId", webLog.themePath) + cmd.Parameters.AddWithValue ("@urlBase", webLog.urlBase) + cmd.Parameters.AddWithValue ("@timeZone", webLog.timeZone) + cmd.Parameters.AddWithValue ("@autoHtmx", webLog.autoHtmx) + ] |> ignore + addWebLogRssParameters cmd webLog + + /// Add parameters for web log user INSERT or UPDATE statements + let addWebLogUserParameters (cmd : SqliteCommand) (user : WebLogUser) = + [ cmd.Parameters.AddWithValue ("@id", WebLogUserId.toString user.id) + cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString user.webLogId) + cmd.Parameters.AddWithValue ("@userName", user.userName) + cmd.Parameters.AddWithValue ("@firstName", user.firstName) + cmd.Parameters.AddWithValue ("@lastName", user.lastName) + cmd.Parameters.AddWithValue ("@preferredName", user.preferredName) + cmd.Parameters.AddWithValue ("@passwordHash", user.passwordHash) + cmd.Parameters.AddWithValue ("@salt", user.salt) + cmd.Parameters.AddWithValue ("@url", maybe user.url) + cmd.Parameters.AddWithValue ("@authorizationLevel", AuthorizationLevel.toString user.authorizationLevel) + ] |> ignore + + /// Add a web log ID parameter + let addWebLogId (cmd : SqliteCommand) webLogId = + cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString webLogId) |> ignore + + // -- PAGE STUFF -- + + /// Append meta items to a page + let appendPageMeta (page : Page) = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT name, value FROM page_meta WHERE page_id = @id" + cmd.Parameters.AddWithValue ("@id", PageId.toString page.id) |> ignore + use! rdr = cmd.ExecuteReaderAsync () + return { page with metadata = toList Map.toMetaItem rdr } + } + + /// Append revisions and permalinks to a page + let appendPageRevisionsAndPermalinks (page : Page) = backgroundTask { + use cmd = conn.CreateCommand () + cmd.Parameters.AddWithValue ("@pageId", PageId.toString page.id) |> ignore + + cmd.CommandText <- "SELECT permalink FROM page_permalink WHERE page_id = @pageId" + use! rdr = cmd.ExecuteReaderAsync () + let page = { page with priorPermalinks = toList Map.toPermalink rdr } + do! rdr.CloseAsync () + + cmd.CommandText <- "SELECT as_of, revision_text FROM page_revision WHERE page_id = @pageId ORDER BY as_of DESC" + use! rdr = cmd.ExecuteReaderAsync () + return { page with revisions = toList Map.toRevision rdr } + } + + /// Return a page with no text (or meta items, prior permalinks, or revisions) + let pageWithoutTextOrMeta rdr = + { Map.toPage rdr with text = "" } + + /// Find meta items added and removed + let diffMetaItems (oldItems : MetaItem list) newItems = + diffLists oldItems newItems (fun item -> $"{item.name}|{item.value}") + + /// Find the permalinks added and removed + let diffPermalinks oldLinks newLinks = + diffLists oldLinks newLinks Permalink.toString + + /// Find the revisions added and removed + let diffRevisions oldRevs newRevs = + diffLists oldRevs newRevs (fun (rev : Revision) -> $"{rev.asOf.Ticks}|{MarkupText.toString rev.text}") + + /// Update a page's metadata items + let updatePageMeta pageId oldItems newItems = backgroundTask { + let toDelete, toAdd = diffMetaItems oldItems newItems + if List.isEmpty toDelete && List.isEmpty toAdd then + return () + else + use cmd = conn.CreateCommand () + [ cmd.Parameters.AddWithValue ("@pageId", PageId.toString pageId) + cmd.Parameters.Add ("@name", SqliteType.Text) + cmd.Parameters.Add ("@value", SqliteType.Text) + ] |> ignore + let runCmd (item : MetaItem) = backgroundTask { + cmd.Parameters["@name" ].Value <- item.name + cmd.Parameters["@value"].Value <- item.value + do! write cmd + } + cmd.CommandText <- "DELETE FROM page_meta WHERE page_id = @pageId AND name = @name AND value = @value" + toDelete + |> List.map runCmd + |> Task.WhenAll + |> ignore + cmd.CommandText <- "INSERT INTO page_meta VALUES (@pageId, @name, @value)" + toAdd + |> List.map runCmd + |> Task.WhenAll + |> ignore + } + + /// Update a page's prior permalinks + let updatePagePermalinks pageId oldLinks newLinks = backgroundTask { + let toDelete, toAdd = diffPermalinks oldLinks newLinks + if List.isEmpty toDelete && List.isEmpty toAdd then + return () + else + use cmd = conn.CreateCommand () + [ cmd.Parameters.AddWithValue ("@pageId", PageId.toString pageId) + cmd.Parameters.Add ("@link", SqliteType.Text) + ] |> ignore + let runCmd link = backgroundTask { + cmd.Parameters["@link"].Value <- Permalink.toString link + do! write cmd + } + cmd.CommandText <- "DELETE FROM page_permalink WHERE page_id = @pageId AND permalink = @link" + toDelete + |> List.map runCmd + |> Task.WhenAll + |> ignore + cmd.CommandText <- "INSERT INTO page_permalink VALUES (@pageId, @link)" + toAdd + |> List.map runCmd + |> Task.WhenAll + |> ignore + } + + /// Update a page's revisions + let updatePageRevisions pageId oldRevs newRevs = backgroundTask { + let toDelete, toAdd = diffRevisions oldRevs newRevs + if List.isEmpty toDelete && List.isEmpty toAdd then + return () + else + use cmd = conn.CreateCommand () + let runCmd withText rev = backgroundTask { + cmd.Parameters.Clear () + [ cmd.Parameters.AddWithValue ("@pageId", PageId.toString pageId) + cmd.Parameters.AddWithValue ("@asOf", rev.asOf) + ] |> ignore + if withText then cmd.Parameters.AddWithValue ("@text", MarkupText.toString rev.text) |> ignore + do! write cmd + } + cmd.CommandText <- "DELETE FROM page_revision WHERE page_id = @pageId AND as_of = @asOf" + toDelete + |> List.map (runCmd false) + |> Task.WhenAll + |> ignore + cmd.CommandText <- "INSERT INTO page_revision VALUES (@pageId, @asOf, @text)" + toAdd + |> List.map (runCmd true) + |> Task.WhenAll + |> ignore + } + + // -- POST STUFF -- + + /// Append category IDs, tags, and meta items to a post + let appendPostCategoryTagAndMeta (post : Post) = backgroundTask { + use cmd = conn.CreateCommand () + cmd.Parameters.AddWithValue ("@id", PostId.toString post.id) |> ignore + + cmd.CommandText <- "SELECT category_id AS id FROM post_category WHERE post_id = @id" + use! rdr = cmd.ExecuteReaderAsync () + let post = { post with categoryIds = toList Map.toCategoryId rdr } + do! rdr.CloseAsync () + + cmd.CommandText <- "SELECT tag FROM post_tag WHERE post_id = @id" + use! rdr = cmd.ExecuteReaderAsync () + let post = { post with tags = toList (Map.getString "tag") rdr } + do! rdr.CloseAsync () + + cmd.CommandText <- "SELECT name, value FROM post_meta WHERE post_id = @id" + use! rdr = cmd.ExecuteReaderAsync () + return { post with metadata = toList Map.toMetaItem rdr } + } + + /// Append revisions and permalinks to a post + let appendPostRevisionsAndPermalinks (post : Post) = backgroundTask { + use cmd = conn.CreateCommand () + cmd.Parameters.AddWithValue ("@postId", PostId.toString post.id) |> ignore + + cmd.CommandText <- "SELECT permalink FROM post_permalink WHERE post_id = @postId" + use! rdr = cmd.ExecuteReaderAsync () + let post = { post with priorPermalinks = toList Map.toPermalink rdr } + do! rdr.CloseAsync () + + cmd.CommandText <- "SELECT as_of, revision_text FROM post_revision WHERE post_id = @postId ORDER BY as_of DESC" + use! rdr = cmd.ExecuteReaderAsync () + return { post with revisions = toList Map.toRevision rdr } + } + + /// Return a post with no revisions, prior permalinks, or text + let postWithoutText rdr = + { Map.toPost rdr with text = "" } + + /// Update a post's assigned categories + let updatePostCategories postId oldCats newCats = backgroundTask { + let toDelete, toAdd = diffLists oldCats newCats CategoryId.toString + if List.isEmpty toDelete && List.isEmpty toAdd then + return () + else + use cmd = conn.CreateCommand () + [ cmd.Parameters.AddWithValue ("@postId", PostId.toString postId) + cmd.Parameters.Add ("@categoryId", SqliteType.Text) + ] |> ignore + let runCmd catId = backgroundTask { + cmd.Parameters["@categoryId"].Value <- CategoryId.toString catId + do! write cmd + } + cmd.CommandText <- "DELETE FROM post_category WHERE post_id = @postId AND category_id = @categoryId" + toDelete + |> List.map runCmd + |> Task.WhenAll + |> ignore + cmd.CommandText <- "INSERT INTO post_category VALUES (@postId, @categoryId)" + toAdd + |> List.map runCmd + |> Task.WhenAll + |> ignore + } + + + /// Update a post's assigned categories + let updatePostTags postId (oldTags : string list) newTags = backgroundTask { + let toDelete, toAdd = diffLists oldTags newTags id + if List.isEmpty toDelete && List.isEmpty toAdd then + return () + else + use cmd = conn.CreateCommand () + [ cmd.Parameters.AddWithValue ("@postId", PostId.toString postId) + cmd.Parameters.Add ("@tag", SqliteType.Text) + ] |> ignore + let runCmd (tag : string) = backgroundTask { + cmd.Parameters["@tag"].Value <- tag + do! write cmd + } + cmd.CommandText <- "DELETE FROM post_tag WHERE post_id = @postId AND tag = @tag" + toDelete + |> List.map runCmd + |> Task.WhenAll + |> ignore + cmd.CommandText <- "INSERT INTO post_tag VALUES (@postId, @tag)" + toAdd + |> List.map runCmd + |> Task.WhenAll + |> ignore + } + + /// Update a post's metadata items + let updatePostMeta postId oldItems newItems = backgroundTask { + let toDelete, toAdd = diffMetaItems oldItems newItems + if List.isEmpty toDelete && List.isEmpty toAdd then + return () + else + use cmd = conn.CreateCommand () + [ cmd.Parameters.AddWithValue ("@postId", PostId.toString postId) + cmd.Parameters.Add ("@name", SqliteType.Text) + cmd.Parameters.Add ("@value", SqliteType.Text) + ] |> ignore + let runCmd (item : MetaItem) = backgroundTask { + cmd.Parameters["@name" ].Value <- item.name + cmd.Parameters["@value"].Value <- item.value + do! write cmd + } + cmd.CommandText <- "DELETE FROM post_meta WHERE post_id = @postId AND name = @name AND value = @value" + toDelete + |> List.map runCmd + |> Task.WhenAll + |> ignore + cmd.CommandText <- "INSERT INTO post_meta VALUES (@postId, @name, @value)" + toAdd + |> List.map runCmd + |> Task.WhenAll + |> ignore + } + + /// Update a post's prior permalinks + let updatePostPermalinks postId oldLinks newLinks = backgroundTask { + let toDelete, toAdd = diffPermalinks oldLinks newLinks + if List.isEmpty toDelete && List.isEmpty toAdd then + return () + else + use cmd = conn.CreateCommand () + [ cmd.Parameters.AddWithValue ("@postId", PostId.toString postId) + cmd.Parameters.Add ("@link", SqliteType.Text) + ] |> ignore + let runCmd link = backgroundTask { + cmd.Parameters["@link"].Value <- Permalink.toString link + do! write cmd + } + cmd.CommandText <- "DELETE FROM post_permalink WHERE post_id = @postId AND permalink = @link" + toDelete + |> List.map runCmd + |> Task.WhenAll + |> ignore + cmd.CommandText <- "INSERT INTO post_permalink VALUES (@postId, @link)" + toAdd + |> List.map runCmd + |> Task.WhenAll + |> ignore + } + + /// Update a post's revisions + let updatePostRevisions postId oldRevs newRevs = backgroundTask { + let toDelete, toAdd = diffRevisions oldRevs newRevs + if List.isEmpty toDelete && List.isEmpty toAdd then + return () + else + use cmd = conn.CreateCommand () + let runCmd withText rev = backgroundTask { + cmd.Parameters.Clear () + [ cmd.Parameters.AddWithValue ("@postId", PostId.toString postId) + cmd.Parameters.AddWithValue ("@asOf", rev.asOf) + ] |> ignore + if withText then cmd.Parameters.AddWithValue ("@text", MarkupText.toString rev.text) |> ignore + do! write cmd + } + cmd.CommandText <- "DELETE FROM post_revision WHERE post_id = @postId AND as_of = @asOf" + toDelete + |> List.map (runCmd false) + |> Task.WhenAll + |> ignore + cmd.CommandText <- "INSERT INTO post_revision VALUES (@postId, @asOf, @text)" + toAdd + |> List.map (runCmd true) + |> Task.WhenAll + |> ignore + } + + /// Append custom feeds to a web log + let appendCustomFeeds (webLog : WebLog) = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- + """SELECT f.*, p.* + FROM web_log_feed f + LEFT JOIN web_log_feed_podcast p ON p.feed_id = f.id + WHERE f.web_log_id = @webLogId""" + addWebLogId cmd webLog.id + use! rdr = cmd.ExecuteReaderAsync () + return { webLog with rss = { webLog.rss with customFeeds = toList Map.toCustomFeed rdr } } + } + + /// Determine if the given table exists + let tableExists (table : string) = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = @table" + cmd.Parameters.AddWithValue ("@table", table) |> ignore + let! count = count cmd + return count = 1 + } + + + /// The connection for this instance + member _.Conn = conn + + /// Make a SQLite connection ready to execute commends + static member setUpConnection (conn : SqliteConnection) = backgroundTask { + do! conn.OpenAsync () + use cmd = conn.CreateCommand () + cmd.CommandText <- "PRAGMA foreign_keys = TRUE" + let! _ = cmd.ExecuteNonQueryAsync () + () + } + + interface IData with + + member _.Category = { + new ICategoryData with + + member _.add cat = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- + """INSERT INTO category ( + id, web_log_id, name, slug, description, parent_id + ) VALUES ( + @id, @webLogId, @name, @slug, @description, @parentId + )""" + addCategoryParameters cmd cat + let! _ = cmd.ExecuteNonQueryAsync () + () + } + + member _.countAll webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT COUNT(id) FROM category WHERE web_log_id = @webLogId" + addWebLogId cmd webLogId + return! count cmd + } + + member _.countTopLevel webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- + "SELECT COUNT(id) FROM category WHERE web_log_id = @webLogId AND parent_id IS NULL" + addWebLogId cmd webLogId + return! count cmd + } + + member _.findAllForView webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT * FROM category WHERE web_log_id = @webLogId" + addWebLogId cmd webLogId + use! rdr = cmd.ExecuteReaderAsync () + let cats = + seq { + while rdr.Read () do + Map.toCategory rdr + } + |> Seq.sortBy (fun cat -> cat.name.ToLowerInvariant ()) + |> List.ofSeq + do! rdr.CloseAsync () + let ordered = Utils.orderByHierarchy cats None None [] + let! counts = + ordered + |> Seq.map (fun it -> backgroundTask { + // Parent category post counts include posts in subcategories + cmd.Parameters.Clear () + addWebLogId cmd webLogId + cmd.CommandText <- + """SELECT COUNT(DISTINCT p.id) + FROM post p + INNER JOIN post_category pc ON pc.post_id = p.id + WHERE p.web_log_id = @webLogId + AND p.status = 'Published' + AND pc.category_id IN (""" + ordered + |> Seq.filter (fun cat -> cat.parentNames |> Array.contains it.name) + |> Seq.map (fun cat -> cat.id) + |> Seq.append (Seq.singleton it.id) + |> Seq.iteri (fun idx item -> + if idx > 0 then cmd.CommandText <- $"{cmd.CommandText}, " + cmd.CommandText <- $"{cmd.CommandText}@catId{idx}" + cmd.Parameters.AddWithValue ($"@catId{idx}", item) |> ignore) + cmd.CommandText <- $"{cmd.CommandText})" + let! postCount = count cmd + return it.id, postCount + }) + |> 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 = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT * FROM category WHERE id = @id" + cmd.Parameters.AddWithValue ("@id", CategoryId.toString catId) |> ignore + use! rdr = cmd.ExecuteReaderAsync () + return verifyWebLog webLogId (fun c -> c.webLogId) Map.toCategory rdr + } + + member _.findByWebLog webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT * FROM category WHERE web_log_id = @webLogId" + cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString webLogId) |> ignore + use! rdr = cmd.ExecuteReaderAsync () + return toList Map.toCategory rdr + } + + member this.delete catId webLogId = backgroundTask { + match! this.findById catId webLogId with + | Some _ -> + use cmd = conn.CreateCommand () + // Delete the category off all posts where it is assigned + cmd.CommandText <- + """DELETE FROM post_category + WHERE category_id = @id + AND post_id IN (SELECT id FROM post WHERE web_log_id = @webLogId)""" + let catIdParameter = cmd.Parameters.AddWithValue ("@id", CategoryId.toString catId) + cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString webLogId) |> ignore + do! write cmd + // Delete the category itself + cmd.CommandText <- "DELETE FROM category WHERE id = @id" + cmd.Parameters.Clear () + cmd.Parameters.Add catIdParameter |> ignore + do! write cmd + return true + | None -> return false + } + + member this.restore cats = backgroundTask { + for cat in cats do + do! this.add cat + } + + member _.update cat = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- + """UPDATE category + SET name = @name, + slug = @slug, + description = @description, + parent_id = @parentId + WHERE id = @id + AND web_log_id = @webLogId""" + addCategoryParameters cmd cat + do! write cmd + } + } + + member _.Page = { + new IPageData with + + member _.add page = backgroundTask { + use cmd = conn.CreateCommand () + // The page itself + cmd.CommandText <- + """INSERT INTO page ( + id, web_log_id, author_id, title, permalink, published_on, updated_on, show_in_page_list, + template, page_text + ) VALUES ( + @id, @webLogId, @authorId, @title, @permalink, @publishedOn, @updatedOn, @showInPageList, + @template, @text + )""" + addPageParameters cmd page + do! write cmd + do! updatePageMeta page.id [] page.metadata + do! updatePagePermalinks page.id [] page.priorPermalinks + do! updatePageRevisions page.id [] page.revisions + } + + member _.all webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT * FROM page WHERE web_log_id = @webLogId ORDER BY LOWER(title)" + addWebLogId cmd webLogId + use! rdr = cmd.ExecuteReaderAsync () + return toList pageWithoutTextOrMeta rdr + } + + member _.countAll webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT COUNT(id) FROM page WHERE web_log_id = @webLogId" + addWebLogId cmd webLogId + return! count cmd + } + + member _.countListed webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- + """SELECT COUNT(id) + FROM page + WHERE web_log_id = @webLogId + AND show_in_page_list = @showInPageList""" + addWebLogId cmd webLogId + cmd.Parameters.AddWithValue ("@showInPageList", true) |> ignore + return! count cmd + } + + member _.findById pageId webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT * FROM page WHERE id = @id" + cmd.Parameters.AddWithValue ("@id", PageId.toString pageId) |> ignore + use! rdr = cmd.ExecuteReaderAsync () + match verifyWebLog webLogId (fun it -> it.webLogId) Map.toPage rdr with + | Some page -> + let! page = appendPageMeta page + return Some page + | None -> return None + } + + member this.findFullById pageId webLogId = backgroundTask { + match! this.findById pageId webLogId with + | Some page -> + let! page = appendPageRevisionsAndPermalinks page + return Some page + | None -> return None + } + + member this.delete pageId webLogId = backgroundTask { + match! this.findById pageId webLogId with + | Some _ -> + use cmd = conn.CreateCommand () + cmd.Parameters.AddWithValue ("@id", PageId.toString pageId) |> ignore + cmd.CommandText <- "DELETE FROM page_revision WHERE page_id = @id" + do! write cmd + cmd.CommandText <- "DELETE FROM page_permalink WHERE page_id = @id" + do! write cmd + cmd.CommandText <- "DELETE FROM page_meta WHERE page_id = @id" + do! write cmd + cmd.CommandText <- "DELETE FROM page WHERE id = @id" + do! write cmd + return true + | None -> return false + } + + member _.findByPermalink permalink webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT * FROM page WHERE web_log_id = @webLogId AND permalink = @link" + addWebLogId cmd webLogId + cmd.Parameters.AddWithValue ("@link", Permalink.toString permalink) |> ignore + use! rdr = cmd.ExecuteReaderAsync () + if rdr.Read () then + let! page = appendPageMeta (Map.toPage rdr) + return Some page + else + return None + } + + member _.findCurrentPermalink permalinks webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- + """SELECT p.permalink + FROM page p + INNER JOIN page_permalink pp ON pp.page_id = p.id + WHERE p.web_log_id = @webLogId + AND pp.permalink IN (""" + permalinks + |> List.iteri (fun idx link -> + if idx > 0 then cmd.CommandText <- $"{cmd.CommandText}, " + cmd.CommandText <- $"{cmd.CommandText}@link{idx}" + cmd.Parameters.AddWithValue ($"@link{idx}", Permalink.toString link) |> ignore) + cmd.CommandText <- $"{cmd.CommandText})" + addWebLogId cmd webLogId + use! rdr = cmd.ExecuteReaderAsync () + return if rdr.Read () then Some (Map.toPermalink rdr) else None + } + + member _.findFullByWebLog webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT * FROM page WHERE web_log_id = @webLogId" + addWebLogId cmd webLogId + use! rdr = cmd.ExecuteReaderAsync () + let! pages = + toList Map.toPage rdr + |> List.map (fun page -> backgroundTask { + let! page = appendPageMeta page + return! appendPageRevisionsAndPermalinks page + }) + |> Task.WhenAll + return List.ofArray pages + } + + member _.findListed webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- + """SELECT * + FROM page + WHERE web_log_id = @webLogId + AND show_in_page_list = @showInPageList + ORDER BY LOWER(title)""" + addWebLogId cmd webLogId + cmd.Parameters.AddWithValue ("@showInPageList", true) |> ignore + use! rdr = cmd.ExecuteReaderAsync () + let! pages = + toList pageWithoutTextOrMeta rdr + |> List.map (fun page -> backgroundTask { return! appendPageMeta page }) + |> Task.WhenAll + return List.ofArray pages + } + + member _.findPageOfPages webLogId pageNbr = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- + """SELECT * + FROM page + WHERE web_log_id = @webLogId + ORDER BY LOWER(title) + LIMIT @pageSize OFFSET @toSkip""" + addWebLogId cmd webLogId + [ cmd.Parameters.AddWithValue ("@pageSize", 26) + cmd.Parameters.AddWithValue ("@toSkip", (pageNbr - 1) * 25) + ] |> ignore + use! rdr = cmd.ExecuteReaderAsync () + return toList Map.toPage rdr + } + + member this.restore pages = backgroundTask { + for page in pages do + do! this.add page + } + + member this.update page = backgroundTask { + match! this.findFullById page.id page.webLogId with + | Some oldPage -> + use cmd = conn.CreateCommand () + cmd.CommandText <- + """UPDATE page + SET author_id = @authorId, + title = @title, + permalink = @permalink, + published_on = @publishedOn, + updated_on = @updatedOn, + show_in_page_list = @showInPageList, + template = @template, + page_text = @text + WHERE id = @pageId + AND web_log_id = @webLogId""" + addPageParameters cmd page + do! write cmd + do! updatePageMeta page.id oldPage.metadata page.metadata + do! updatePagePermalinks page.id oldPage.priorPermalinks page.priorPermalinks + do! updatePageRevisions page.id oldPage.revisions page.revisions + return () + | None -> return () + } + + member this.updatePriorPermalinks pageId webLogId permalinks = backgroundTask { + match! this.findFullById pageId webLogId with + | Some page -> + do! updatePagePermalinks pageId page.priorPermalinks permalinks + return true + | None -> return false + } + } + + member _.Post = { + new IPostData with + + member _.add post = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- + """INSERT INTO post ( + id, web_log_id, author_id, status, title, permalink, published_on, updated_on, + template, post_text + ) VALUES ( + @id, @webLogId, @authorId, @status, @title, @permalink, @publishedOn, @updatedOn, + @template, @text + )""" + addPostParameters cmd post + do! write cmd + do! updatePostCategories post.id [] post.categoryIds + do! updatePostTags post.id [] post.tags + do! updatePostMeta post.id [] post.metadata + do! updatePostPermalinks post.id [] post.priorPermalinks + do! updatePostRevisions post.id [] post.revisions + } + + member _.countByStatus status webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- + "SELECT COUNT(id) FROM post WHERE web_log_id = @webLogId AND status = @status" + addWebLogId cmd webLogId + cmd.Parameters.AddWithValue ("@status", PostStatus.toString status) |> ignore + return! count cmd + } + + member _.findByPermalink permalink webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT * FROM post WHERE web_log_id = @webLogId AND permalink = @link" + addWebLogId cmd webLogId + cmd.Parameters.AddWithValue ("@link", Permalink.toString permalink) |> ignore + use! rdr = cmd.ExecuteReaderAsync () + if rdr.Read () then + let! post = appendPostCategoryTagAndMeta (Map.toPost rdr) + return Some post + else + return None + } + + member _.findFullById postId webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT * FROM post WHERE id = @id" + cmd.Parameters.AddWithValue ("@id", PostId.toString postId) |> ignore + use! rdr = cmd.ExecuteReaderAsync () + match verifyWebLog webLogId (fun p -> p.webLogId) Map.toPost rdr with + | Some post -> + let! post = appendPostCategoryTagAndMeta post + let! post = appendPostRevisionsAndPermalinks post + return Some post + | None -> + return None + } + + member this.delete postId webLogId = backgroundTask { + match! this.findFullById postId webLogId with + | Some _ -> + use cmd = conn.CreateCommand () + cmd.Parameters.AddWithValue ("@id", PostId.toString postId) |> ignore + cmd.CommandText <- "DELETE FROM post_revision WHERE post_id = @id" + do! write cmd + cmd.CommandText <- "DELETE FROM post_permalink WHERE post_id = @id" + do! write cmd + cmd.CommandText <- "DELETE FROM post_meta WHERE post_id = @id" + do! write cmd + cmd.CommandText <- "DELETE FROM post_tag WHERE post_id = @id" + do! write cmd + cmd.CommandText <- "DELETE FROM post_category WHERE post_id = @id" + do! write cmd + cmd.CommandText <- "DELETE FROM post WHERE id = @id" + do! write cmd + return true + | None -> return false + } + + member _.findCurrentPermalink permalinks webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- + """SELECT p.permalink + FROM post p + INNER JOIN post_permalink pp ON pp.post_id = p.id + WHERE p.web_log_id = @webLogId + AND pp.permalink IN (""" + permalinks + |> List.iteri (fun idx link -> + if idx > 0 then cmd.CommandText <- $"{cmd.CommandText}, " + cmd.CommandText <- $"{cmd.CommandText}@link{idx}" + cmd.Parameters.AddWithValue ($"@link{idx}", Permalink.toString link) |> ignore) + cmd.CommandText <- $"{cmd.CommandText})" + addWebLogId cmd webLogId + use! rdr = cmd.ExecuteReaderAsync () + return if rdr.Read () then Some (Map.toPermalink rdr) else None + } + + member _.findFullByWebLog webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT * FROM post WHERE web_log_id = @webLogId" + addWebLogId cmd webLogId + use! rdr = cmd.ExecuteReaderAsync () + let! posts = + toList Map.toPost rdr + |> List.map (fun post -> backgroundTask { + let! post = appendPostCategoryTagAndMeta post + return! appendPostRevisionsAndPermalinks post + }) + |> Task.WhenAll + return List.ofArray posts + } + + member _.findPageOfCategorizedPosts webLogId categoryIds pageNbr postsPerPage = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- + """SELECT p.* + FROM post p + INNER JOIN post_category pc ON pc.post_id = p.id + WHERE p.web_log_id = @webLogId + AND p.status = @status + AND pc.category_id IN (""" + categoryIds + |> List.iteri (fun idx catId -> + if idx > 0 then cmd.CommandText <- $"{cmd.CommandText}, " + cmd.CommandText <- $"{cmd.CommandText}@catId{idx}" + cmd.Parameters.AddWithValue ($"@catId{idx}", CategoryId.toString catId) |> ignore) + cmd.CommandText <- + $"""{cmd.CommandText}) + ORDER BY published_on DESC + LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}""" + addWebLogId cmd webLogId + cmd.Parameters.AddWithValue ("@status", PostStatus.toString Published) |> ignore + use! rdr = cmd.ExecuteReaderAsync () + let! posts = + toList Map.toPost rdr + |> List.map (fun post -> backgroundTask { return! appendPostCategoryTagAndMeta post }) + |> Task.WhenAll + return List.ofArray posts + } + + member _.findPageOfPosts webLogId pageNbr postsPerPage = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- + $"""SELECT p.* + FROM post p + WHERE p.web_log_id = @webLogId + ORDER BY published_on DESC NULLS FIRST, updated_on + LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}""" + addWebLogId cmd webLogId + use! rdr = cmd.ExecuteReaderAsync () + let! posts = + toList postWithoutText rdr + |> List.map (fun post -> backgroundTask { return! appendPostCategoryTagAndMeta post }) + |> Task.WhenAll + return List.ofArray posts + } + + member _.findPageOfPublishedPosts webLogId pageNbr postsPerPage = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- + $"""SELECT p.* + FROM post p + WHERE p.web_log_id = @webLogId + AND p.status = @status + ORDER BY published_on DESC + LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}""" + addWebLogId cmd webLogId + cmd.Parameters.AddWithValue ("@status", PostStatus.toString Published) |> ignore + use! rdr = cmd.ExecuteReaderAsync () + let! posts = + toList Map.toPost rdr + |> List.map (fun post -> backgroundTask { return! appendPostCategoryTagAndMeta post }) + |> Task.WhenAll + return List.ofArray posts + } + + member _.findPageOfTaggedPosts webLogId tag pageNbr postsPerPage = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- + $"""SELECT p.* + FROM post p + INNER JOIN post_tag pt ON pt.post_id = p.id + WHERE p.web_log_id = @webLogId + AND p.status = @status + AND pt.tag = @tag + ORDER BY published_on DESC + LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}""" + addWebLogId cmd webLogId + [ cmd.Parameters.AddWithValue ("@status", PostStatus.toString Published) + cmd.Parameters.AddWithValue ("@tag", tag) + ] |> ignore + use! rdr = cmd.ExecuteReaderAsync () + let! posts = + toList Map.toPost rdr + |> List.map (fun post -> backgroundTask { return! appendPostCategoryTagAndMeta post }) + |> Task.WhenAll + return List.ofArray posts + } + + member _.findSurroundingPosts webLogId publishedOn = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- + """SELECT * + FROM post + WHERE web_log_id = @webLogId + AND status = @status + AND published_on < @publishedOn + ORDER BY published_on DESC + LIMIT 1""" + addWebLogId cmd webLogId + [ cmd.Parameters.AddWithValue ("@status", PostStatus.toString Published) + cmd.Parameters.AddWithValue ("@publishedOn", publishedOn) + ] |> ignore + use! rdr = cmd.ExecuteReaderAsync () + let! older = backgroundTask { + if rdr.Read () then + let! post = appendPostCategoryTagAndMeta (postWithoutText rdr) + return Some post + else + return None + } + do! rdr.CloseAsync () + cmd.CommandText <- + """SELECT * + FROM post + WHERE web_log_id = @webLogId + AND status = @status + AND published_on > @publishedOn + ORDER BY published_on + LIMIT 1""" + use! rdr = cmd.ExecuteReaderAsync () + let! newer = backgroundTask { + if rdr.Read () then + let! post = appendPostCategoryTagAndMeta (postWithoutText rdr) + return Some post + else + return None + } + return older, newer + } + + member this.restore posts = backgroundTask { + for post in posts do + do! this.add post + } + + member this.update post = backgroundTask { + match! this.findFullById post.id post.webLogId with + | Some oldPost -> + use cmd = conn.CreateCommand () + cmd.CommandText <- + """UPDATE post + SET author_id = @author_id, + status = @status, + title = @title, + permalink = @permalink, + published_on = @publishedOn, + updated_on = @updatedOn, + template = @template, + post_text = @text + WHERE id = @id + AND web_log_id = @webLogId""" + addPostParameters cmd post + do! write cmd + do! updatePostCategories post.id oldPost.categoryIds post.categoryIds + do! updatePostTags post.id oldPost.tags post.tags + do! updatePostMeta post.id oldPost.metadata post.metadata + do! updatePostPermalinks post.id oldPost.priorPermalinks post.priorPermalinks + do! updatePostRevisions post.id oldPost.revisions post.revisions + | None -> return () + } + + member this.updatePriorPermalinks postId webLogId permalinks = backgroundTask { + match! this.findFullById postId webLogId with + | Some post -> + do! updatePostPermalinks postId post.priorPermalinks permalinks + return true + | None -> return false + } + } + + member _.TagMap = { + new ITagMapData with + + member _.findById tagMapId webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT * FROM tag_map WHERE id = @id" + cmd.Parameters.AddWithValue ("@id", TagMapId.toString tagMapId) |> ignore + use! rdr = cmd.ExecuteReaderAsync () + return verifyWebLog webLogId (fun tm -> tm.webLogId) Map.toTagMap rdr + } + + member this.delete tagMapId webLogId = backgroundTask { + match! this.findById tagMapId webLogId with + | Some _ -> + use cmd = conn.CreateCommand () + cmd.CommandText <- "DELETE FROM tag_map WHERE id = @id" + cmd.Parameters.AddWithValue ("@id", TagMapId.toString tagMapId) |> ignore + do! write cmd + return true + | None -> return false + } + + member _.findByUrlValue urlValue webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT * FROM tag_map WHERE web_log_id = @webLogId AND url_value = @urlValue" + addWebLogId cmd webLogId + cmd.Parameters.AddWithValue ("@urlValue", urlValue) |> ignore + use! rdr = cmd.ExecuteReaderAsync () + return if rdr.Read () then Some (Map.toTagMap rdr) else None + } + + member _.findByWebLog webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT * FROM tag_map WHERE web_log_id = @webLogId ORDER BY tag" + addWebLogId cmd webLogId + use! rdr = cmd.ExecuteReaderAsync () + return toList Map.toTagMap rdr + } + + member _.findMappingForTags tags webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- + """SELECT * + FROM tag_map + WHERE web_log_id = @webLogId + AND tag IN (""" + tags + |> List.iteri (fun idx tag -> + if idx > 0 then cmd.CommandText <- $"{cmd.CommandText}, " + cmd.CommandText <- $"{cmd.CommandText}@tag{idx}" + cmd.Parameters.AddWithValue ($"@tag{idx}", tag) |> ignore) + cmd.CommandText <- $"{cmd.CommandText})" + addWebLogId cmd webLogId + use! rdr = cmd.ExecuteReaderAsync () + return toList Map.toTagMap rdr + } + + member this.save tagMap = backgroundTask { + use cmd = conn.CreateCommand () + match! this.findById tagMap.id tagMap.webLogId with + | Some _ -> + cmd.CommandText <- + """UPDATE tag_map + SET tag = @tag, + url_value = @urlValue + WHERE id = @id + AND web_log_id = @webLogId""" + | None -> + cmd.CommandText <- + """INSERT INTO tag_map ( + id, web_log_id, tag, url_value + ) VALUES ( + @id, @webLogId, @tag, @urlValue + )""" + addWebLogId cmd tagMap.webLogId + [ cmd.Parameters.AddWithValue ("@id", TagMapId.toString tagMap.id) + cmd.Parameters.AddWithValue ("@tag", tagMap.tag) + cmd.Parameters.AddWithValue ("@urlValue", tagMap.urlValue) + ] |> ignore + do! write cmd + } + + member this.restore tagMaps = backgroundTask { + for tagMap in tagMaps do + do! this.save tagMap + } + } + + member _.Theme = { + new IThemeData with + + member _.all () = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT * FROM theme WHERE id <> 'admin' ORDER BY id" + use! rdr = cmd.ExecuteReaderAsync () + return toList Map.toTheme rdr + } + + member _.findById themeId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT * FROM theme WHERE id = @id" + cmd.Parameters.AddWithValue ("@id", ThemeId.toString themeId) |> ignore + use! rdr = cmd.ExecuteReaderAsync () + if rdr.Read () then + let theme = Map.toTheme rdr + let templateCmd = conn.CreateCommand () + templateCmd.CommandText <- "SELECT * FROM theme_template WHERE theme_id = @id" + templateCmd.Parameters.Add cmd.Parameters["@id"] |> ignore + use! templateRdr = templateCmd.ExecuteReaderAsync () + return Some { theme with templates = toList Map.toThemeTemplate templateRdr } + else + return None + } + + member this.findByIdWithoutText themeId = backgroundTask { + match! this.findById themeId with + | Some theme -> + return Some { + theme with templates = theme.templates |> List.map (fun t -> { t with text = "" }) + } + | None -> return None + } + + member this.save theme = backgroundTask { + use cmd = conn.CreateCommand () + let! oldTheme = this.findById theme.id + cmd.CommandText <- + match oldTheme with + | Some _ -> "UPDATE theme SET name = @name, version = @version WHERE id = @id" + | None -> "INSERT INTO theme VALUES (@id, @name, @version)" + [ cmd.Parameters.AddWithValue ("@id", ThemeId.toString theme.id) + cmd.Parameters.AddWithValue ("@name", theme.name) + cmd.Parameters.AddWithValue ("@version", theme.version) + ] |> ignore + do! write cmd + + let toDelete, toAdd = + diffLists (oldTheme |> Option.map (fun t -> t.templates) |> Option.defaultValue []) + theme.templates (fun t -> t.name) + let toUpdate = + theme.templates + |> List.filter (fun t -> + not (toDelete |> List.exists (fun d -> d.name = t.name)) + && not (toAdd |> List.exists (fun a -> a.name = t.name))) + cmd.CommandText <- + "UPDATE theme_template SET template = @template WHERE theme_id = @themeId AND name = @name" + cmd.Parameters.Clear () + [ cmd.Parameters.AddWithValue ("@themeId", ThemeId.toString theme.id) + cmd.Parameters.Add ("@name", SqliteType.Text) + cmd.Parameters.Add ("@template", SqliteType.Text) + ] |> ignore + toUpdate + |> List.map (fun template -> backgroundTask { + cmd.Parameters["@name" ].Value <- template.name + cmd.Parameters["@template"].Value <- template.text + do! write cmd + }) + |> Task.WhenAll + |> ignore + cmd.CommandText <- "INSERT INTO theme_template VALUES (@themeId, @name, @template)" + toAdd + |> List.map (fun template -> backgroundTask { + cmd.Parameters["@name" ].Value <- template.name + cmd.Parameters["@template"].Value <- template.text + do! write cmd + }) + |> Task.WhenAll + |> ignore + cmd.CommandText <- "DELETE FROM theme_template WHERE theme_id = @themeId AND name = @name" + cmd.Parameters.Remove cmd.Parameters["@template"] + toDelete + |> List.map (fun template -> backgroundTask { + cmd.Parameters["@name"].Value <- template.name + do! write cmd + }) + |> Task.WhenAll + |> ignore + } + } + + member _.ThemeAsset = { + new IThemeAssetData with + + member _.all () = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT theme_id, path, updated_on FROM theme_asset" + use! rdr = cmd.ExecuteReaderAsync () + return toList (Map.toThemeAsset false) rdr + } + + member _.deleteByTheme themeId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "DELETE FROM theme_asset WHERE theme_id = @themeId" + cmd.Parameters.AddWithValue ("@themeId", ThemeId.toString themeId) |> ignore + do! write cmd + } + + member _.findById assetId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT *, ROWID FROM theme_asset WHERE theme_id = @themeId AND path = @path" + let (ThemeAssetId (ThemeId themeId, path)) = assetId + [ cmd.Parameters.AddWithValue ("@themeId", themeId) + cmd.Parameters.AddWithValue ("@path", path) + ] |> ignore + use! rdr = cmd.ExecuteReaderAsync () + return if rdr.Read () then Some (Map.toThemeAsset true rdr) else None + } + + member _.findByTheme themeId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT theme_id, path, updated_on FROM theme_asset WHERE theme_id = @themeId" + cmd.Parameters.AddWithValue ("@themeId", ThemeId.toString themeId) |> ignore + use! rdr = cmd.ExecuteReaderAsync () + return toList (Map.toThemeAsset false) rdr + } + + member _.findByThemeWithData themeId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT *, ROWID FROM theme_asset WHERE theme_id = @themeId" + cmd.Parameters.AddWithValue ("@themeId", ThemeId.toString themeId) |> ignore + use! rdr = cmd.ExecuteReaderAsync () + return toList (Map.toThemeAsset true) rdr + } + + member _.save asset = backgroundTask { + use sideCmd = conn.CreateCommand () + sideCmd.CommandText <- + "SELECT COUNT(path) FROM theme_asset WHERE theme_id = @themeId AND path = @path" + let (ThemeAssetId (ThemeId themeId, path)) = asset.id + [ sideCmd.Parameters.AddWithValue ("@themeId", themeId) + sideCmd.Parameters.AddWithValue ("@path", path) + ] |> ignore + let! exists = count sideCmd + + use cmd = conn.CreateCommand () + cmd.CommandText <- + if exists = 1 then + """UPDATE theme_asset + SET updated_on = @updatedOn, + data = ZEROBLOB(@dataLength) + WHERE theme_id = @themeId + AND path = @path""" + else + """INSERT INTO theme_asset ( + theme_id, path, updated_on, data + ) VALUES ( + @themeId, @path, @updatedOn, ZEROBLOB(@dataLength) + )""" + [ cmd.Parameters.AddWithValue ("@themeId", themeId) + cmd.Parameters.AddWithValue ("@path", path) + cmd.Parameters.AddWithValue ("@updatedOn", asset.updatedOn) + cmd.Parameters.AddWithValue ("@dataLength", asset.data.Length) + ] |> ignore + do! write cmd + + sideCmd.CommandText <- "SELECT ROWID FROM theme_asset WHERE theme_id = @themeId AND path = @path" + let! rowId = sideCmd.ExecuteScalarAsync () + + use dataStream = new MemoryStream (asset.data) + use blobStream = new SqliteBlob (conn, "theme_asset", "data", rowId :?> int64) + do! dataStream.CopyToAsync blobStream + } + } + + member _.WebLog = { + new IWebLogData with + + member _.add webLog = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- + """INSERT INTO web_log ( + id, name, subtitle, default_page, posts_per_page, theme_id, url_base, time_zone, + auto_htmx, feed_enabled, feed_name, items_in_feed, category_enabled, tag_enabled, + copyright + ) VALUES ( + @id, @name, @subtitle, @defaultPage, @postsPerPage, @themeId, @urlBase, @timeZone, + @autoHtmx, @feedEnabled, @feedName, @itemsInFeed, @categoryEnabled, @tagEnabled, + @copyright + )""" + addWebLogParameters cmd webLog + do! write cmd + webLog.rss.customFeeds + |> List.map (fun feed -> backgroundTask { + cmd.CommandText <- + """INSERT INTO web_log_feed ( + id, web_log_id, source, path + ) VALUES ( + @id, @webLogId, @source, @path + )""" + cmd.Parameters.Clear () + [ cmd.Parameters.AddWithValue ("@id", CustomFeedId.toString feed.id) + cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString webLog.id) + cmd.Parameters.AddWithValue ("@source", CustomFeedSource.toString feed.source) + cmd.Parameters.AddWithValue ("@path", Permalink.toString feed.path) + ] |> ignore + do! write cmd + match feed.podcast with + | Some podcast -> + cmd.CommandText <- + """INSERT INTO web_log_feed_podcast ( + feed_id, title, subtitle, items_in_feed, summary, displayed_author, email, + image_url, itunes_category, itunes_subcategory, explicit, default_media_type, + media_base_url + ) VALUES ( + @feedId, @title, @subtitle, @itemsInFeed, @summary, @displayedAuthor, @email, + @imageUrl, @iTunesCategory, @iTunesSubcategory, @explicit, @defaultMediaType, + @mediaBaseUrl + )""" + cmd.Parameters.Clear () + [ cmd.Parameters.AddWithValue ("@feedId", CustomFeedId.toString feed.id) + cmd.Parameters.AddWithValue ("@title", podcast.title) + cmd.Parameters.AddWithValue ("@subtitle", maybe podcast.subtitle) + cmd.Parameters.AddWithValue ("@itemsInFeed", podcast.itemsInFeed) + cmd.Parameters.AddWithValue ("@summary", podcast.summary) + cmd.Parameters.AddWithValue ("@displayedAuthor", podcast.displayedAuthor) + cmd.Parameters.AddWithValue ("@email", podcast.email) + cmd.Parameters.AddWithValue ("@imageUrl", Permalink.toString podcast.imageUrl) + cmd.Parameters.AddWithValue ("@iTunesCategory", podcast.iTunesCategory) + cmd.Parameters.AddWithValue ("@iTunesSubcategory", maybe podcast.iTunesSubcategory) + cmd.Parameters.AddWithValue ("@explicit", ExplicitRating.toString podcast.explicit) + cmd.Parameters.AddWithValue ("@defaultMediaType", maybe podcast.defaultMediaType) + cmd.Parameters.AddWithValue ("@mediaBaseUrl", maybe podcast.mediaBaseUrl) + ] |> ignore + do! write cmd + | None -> () + }) + |> Task.WhenAll + |> ignore + } + + member _.all () = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT * FROM web_log" + use! rdr = cmd.ExecuteReaderAsync () + let! webLogs = + toList Map.toWebLog rdr + |> List.map (fun webLog -> backgroundTask { return! appendCustomFeeds webLog }) + |> Task.WhenAll + return List.ofArray webLogs + } + + member _.delete webLogId = backgroundTask { + use cmd = conn.CreateCommand () + addWebLogId cmd webLogId + let subQuery table = $"(SELECT id FROM {table} WHERE web_log_id = @webLogId)" + let postSubQuery = subQuery "post" + let pageSubQuery = subQuery "page" + [ $"DELETE FROM post_comment WHERE post_id IN {postSubQuery}" + $"DELETE FROM post_revision WHERE post_id IN {postSubQuery}" + $"DELETE FROM post_tag WHERE post_id IN {postSubQuery}" + $"DELETE FROM post_category WHERE post_id IN {postSubQuery}" + $"DELETE FROM post_meta WHERE post_id IN {postSubQuery}" + "DELETE FROM post WHERE web_log_id = @webLogId" + $"DELETE FROM page_revision WHERE page_id IN {pageSubQuery}" + $"DELETE FROM page_meta WHERE page_id IN {pageSubQuery}" + "DELETE FROM page WHERE web_log_id = @webLogId" + "DELETE FROM category WHERE web_log_id = @webLogId" + "DELETE FROM tag_map WHERE web_log_id = @webLogId" + "DELETE FROM web_log_user WHERE web_log_id = @webLogId" + $"""DELETE FROM web_log_feed_podcast WHERE feed_id IN {subQuery "web_log_feed"}""" + "DELETE FROM web_log_feed WHERE web_log_id = @webLogId" + "DELETE FROM web_log WHERE id = @webLogId" + ] + |> List.map (fun query -> backgroundTask { + cmd.CommandText <- query + do! write cmd + }) + |> Task.WhenAll + |> ignore + } + + member _.findByHost url = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT * FROM web_log WHERE url_base = @urlBase" + cmd.Parameters.AddWithValue ("@urlBase", url) |> ignore + use! rdr = cmd.ExecuteReaderAsync () + if rdr.Read () then + let! webLog = appendCustomFeeds (Map.toWebLog rdr) + return Some webLog + else + return None + } + + member _.findById webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT * FROM web_log WHERE id = @webLogId" + addWebLogId cmd webLogId + use! rdr = cmd.ExecuteReaderAsync () + if rdr.Read () then + let! webLog = appendCustomFeeds (Map.toWebLog rdr) + return Some webLog + else + return None + } + + member _.updateSettings webLog = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- + """UPDATE web_log + SET name = @name, + subtitle = @subtitle, + default_page = @defaultPage, + posts_per_page = @postsPerPage, + theme_id = @themeId, + url_base = @urlBase, + time_zone = @timeZone, + auto_htmx = @autoHtmx, + feed_enabled = @feedEnabled, + feed_name = @feedName, + items_in_feed = @itemsInFeed, + category_enabled = @categoryEnabled, + tag_enabled = @tagEnabled, + copyright = @copyright + WHERE id = @id""" + addWebLogParameters cmd webLog + do! write cmd + } + + member this.updateRssOptions webLog = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- + """UPDATE web_log + SET feed_enabled = @feedEnabled, + feed_name = @feedName, + items_in_feed = @itemsInFeed, + category_enabled = @categoryEnabled, + tag_enabled = @tagEnabled, + copyright = @copyright + WHERE id = @id""" + addWebLogRssParameters cmd webLog + do! write cmd + } + } + + member _.WebLogUser = { + new IWebLogUserData with + + member _.add user = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- + """INSERT INTO web_log_user ( + id, web_log_id, user_name, first_name, last_name, preferred_name, password_hash, salt, + url, authorization_level + ) VALUES ( + @id, @webLogId, @userName, @firstName, @lastName, @preferredName, @passwordHash, @salt, + @url, @authorizationLevel + )""" + addWebLogUserParameters cmd user + do! write cmd + } + + member _.findByEmail email webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- + "SELECT * FROM web_log_user WHERE web_log_id = @webLogId AND user_name = @userName" + addWebLogId cmd webLogId + cmd.Parameters.AddWithValue ("@userName", email) |> ignore + use! rdr = cmd.ExecuteReaderAsync () + return if rdr.Read () then Some (Map.toWebLogUser rdr) else None + } + + member _.findById userId webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT * FROM web_log_user WHERE id = @id" + cmd.Parameters.AddWithValue ("@id", WebLogUserId.toString userId) |> ignore + use! rdr = cmd.ExecuteReaderAsync () + return verifyWebLog webLogId (fun u -> u.webLogId) Map.toWebLogUser rdr + } + + member _.findByWebLog webLogId = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT * FROM web_log_user WHERE web_log_id = @webLogId" + addWebLogId cmd webLogId + use! rdr = cmd.ExecuteReaderAsync () + return toList Map.toWebLogUser rdr + } + + member _.findNames webLogId userIds = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "SELECT * FROM web_log_user WHERE web_log_id = @webLogId AND id IN (" + userIds + |> List.iteri (fun idx userId -> + if idx > 0 then cmd.CommandText <- $"{cmd.CommandText}, " + cmd.CommandText <- $"{cmd.CommandText}@id{idx}" + cmd.Parameters.AddWithValue ($"@id{idx}", WebLogUserId.toString userId) |> ignore) + cmd.CommandText <- $"{cmd.CommandText})" + addWebLogId cmd webLogId + use! rdr = cmd.ExecuteReaderAsync () + return + toList Map.toWebLogUser rdr + |> List.map (fun u -> { name = WebLogUserId.toString u.id; value = WebLogUser.displayName u }) + } + + member this.restore users = backgroundTask { + for user in users do + do! this.add user + } + + member _.update user = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- + """UPDATE web_log_user + SET user_name = @userName, + first_name = @firstName, + last_name = @lastName, + preferred_name = @preferredName, + password_hash = @passwordHash, + salt = @salt, + url = @url, + authorization_level = @authorizationLevel + WHERE id = @id + AND web_log_id = @webLogId""" + addWebLogUserParameters cmd user + do! write cmd + } + } + + member _.startUp () = backgroundTask { + + let! exists = tableExists "theme" + if not exists then + log.LogInformation "Creating theme tables..." + use cmd = conn.CreateCommand () + cmd.CommandText <- + """CREATE TABLE theme ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + version TEXT NOT NULL)""" + do! write cmd + cmd.CommandText <- + """CREATE TABLE theme_template ( + theme_id TEXT NOT NULL REFERENCES theme (id), + name TEXT NOT NULL, + template TEXT NOT NULL, + PRIMARY KEY (theme_id, name))""" + do! write cmd + cmd.CommandText <- + """CREATE TABLE theme_asset ( + theme_id TEXT NOT NULL REFERENCES theme (id), + path TEXT NOT NULL, + updated_on TEXT NOT NULL, + data BLOB NOT NULL, + PRIMARY KEY (theme_id, path))""" + do! write cmd + + let! exists = tableExists "web_log" + if not exists then + log.LogInformation "Creating web log tables..." + use cmd = conn.CreateCommand () + cmd.CommandText <- + """CREATE TABLE web_log ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + subtitle TEXT, + default_page TEXT NOT NULL, + posts_per_page INTEGER NOT NULL, + theme_id TEXT NOT NULL REFERENCES theme (id), + url_base TEXT NOT NULL, + time_zone TEXT NOT NULL, + auto_htmx INTEGER NOT NULL DEFAULT 0, + feed_enabled INTEGER NOT NULL DEFAULT 0, + feed_name TEXT NOT NULL, + items_in_feed INTEGER, + category_enabled INTEGER NOT NULL DEFAULT 0, + tag_enabled INTEGER NOT NULL DEFAULT 0, + copyright TEXT)""" + do! write cmd + cmd.CommandText <- + """CREATE TABLE web_log_feed ( + id TEXT PRIMARY KEY, + web_log_id TEXT NOT NULL REFERENCES web_log (id), + source TEXT NOT NULL, + path TEXT NOT NULL)""" + do! write cmd + cmd.CommandText <- + """CREATE TABLE web_log_feed_podcast ( + feed_id TEXT PRIMARY KEY REFERENCES web_log_feed (id), + title TEXT NOT NULL, + subtitle TEXT, + items_in_feed INTEGER NOT NULL, + summary TEXT NOT NULL, + displayed_author TEXT NOT NULL, + email TEXT NOT NULL, + image_url TEXT NOT NULL, + itunes_category TEXT NOT NULL, + itunes_subcategory TEXT, + explicit TEXT NOT NULL, + default_media_type TEXT, + media_base_url TEXT)""" + do! write cmd + + let! exists = tableExists "category" + if not exists then + log.LogInformation "Creating category table..." + use cmd = conn.CreateCommand () + cmd.CommandText <- + """CREATE TABLE category ( + id TEXT PRIMARY KEY, + web_log_id TEXT NOT NULL REFERENCES web_log (id), + name TEXT NOT NULL, + slug TEXT NOT NULL, + description TEXT, + parent_id TEXT)""" + do! write cmd + + let! exists = tableExists "web_log_user" + if not exists then + log.LogInformation "Creating user table..." + use cmd = conn.CreateCommand () + cmd.CommandText <- + """CREATE TABLE web_log_user ( + id TEXT PRIMARY KEY, + web_log_id TEXT NOT NULL REFERENCES web_log (id), + user_name TEXT NOT NULL, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + preferred_name TEXT NOT NULL, + password_hash TEXT NOT NULL, + salt TEXT NOT NULL, + url TEXT, + authorization_level TEXT NOT NULL)""" + do! write cmd + + let! exists = tableExists "page" + if not exists then + log.LogInformation "Creating page tables..." + use cmd = conn.CreateCommand () + cmd.CommandText <- + """CREATE TABLE page ( + id TEXT PRIMARY KEY, + web_log_id TEXT NOT NULL REFERENCES web_log (id), + author_id TEXT NOT NULL REFERENCES web_log_user (id), + title TEXT NOT NULL, + permalink TEXT NOT NULL, + published_on TEXT NOT NULL, + updated_on TEXT NOT NULL, + show_in_page_list INTEGER NOT NULL DEFAULT 0, + template TEXT, + page_text TEXT NOT NULL)""" + do! write cmd + cmd.CommandText <- + """CREATE TABLE page_meta ( + page_id TEXT NOT NULL REFERENCES page (id), + name TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (page_id, name, value))""" + do! write cmd + cmd.CommandText <- + """CREATE TABLE page_permalink ( + page_id TEXT NOT NULL REFERENCES page (id), + permalink TEXT NOT NULL, + PRIMARY KEY (page_id, permalink))""" + do! write cmd + cmd.CommandText <- + """CREATE TABLE page_revision ( + page_id TEXT NOT NULL REFERENCES page (id), + as_of TEXT NOT NULL, + revision_text TEXT NOT NULL, + PRIMARY KEY (page_id, as_of))""" + do! write cmd + + let! exists = tableExists "post" + if not exists then + log.LogInformation "Creating post tables..." + use cmd = conn.CreateCommand () + cmd.CommandText <- + """CREATE TABLE post ( + id TEXT PRIMARY KEY, + web_log_id TEXT NOT NULL REFERENCES web_log (id), + author_id TEXT NOT NULL REFERENCES web_log_user (id), + status TEXT NOT NULL, + title TEXT NOT NULL, + permalink TEXT NOT NULL, + published_on TEXT, + updated_on TEXT NOT NULL, + template TEXT, + post_text TEXT NOT NULL)""" + do! write cmd + cmd.CommandText <- + """CREATE TABLE post_category ( + post_id TEXT NOT NULL REFERENCES post (id), + category_id TEXT NOT NULL REFERENCES category (id), + PRIMARY KEY (post_id, category_id))""" + do! write cmd + cmd.CommandText <- + """CREATE TABLE post_tag ( + post_id TEXT NOT NULL REFERENCES post (id), + tag TEXT NOT NULL, + PRIMARY KEY (post_id, tag))""" + do! write cmd + cmd.CommandText <- + """CREATE TABLE post_meta ( + post_id TEXT NOT NULL REFERENCES post (id), + name TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (post_id, name, value))""" + do! write cmd + cmd.CommandText <- + """CREATE TABLE post_permalink ( + post_id TEXT NOT NULL REFERENCES post (id), + permalink TEXT NOT NULL, + PRIMARY KEY (post_id, permalink))""" + do! write cmd + cmd.CommandText <- + """CREATE TABLE post_revision ( + post_id TEXT NOT NULL REFERENCES post (id), + as_of TEXT NOT NULL, + revision_text TEXT NOT NULL, + PRIMARY KEY (post_id, as_of))""" + do! write cmd + cmd.CommandText <- + """CREATE TABLE post_comment ( + id TEXT PRIMARY KEY, + post_id TEXT NOT NULL REFERENCES post(id), + in_reply_to_id TEXT, + name TEXT NOT NULL, + email TEXT NOT NULL, + url TEXT, + status TEXT NOT NULL, + posted_on TEXT NOT NULL, + comment_text TEXT NOT NULL)""" + do! write cmd + + let! exists = tableExists "tag_map" + if not exists then + log.LogInformation "Creating tag map tables..." + use cmd = conn.CreateCommand () + cmd.CommandText <- + """CREATE TABLE tag_map ( + id TEXT PRIMARY KEY, + web_log_id TEXT NOT NULL REFERENCES web_log (id), + tag TEXT NOT NULL, + url_value TEXT NOT NULL)""" + do! write cmd + } diff --git a/src/MyWebLog.Data/Utils.fs b/src/MyWebLog.Data/Utils.fs new file mode 100644 index 0000000..e435067 --- /dev/null +++ b/src/MyWebLog.Data/Utils.fs @@ -0,0 +1,22 @@ +/// Utility functions for manipulating data +[] +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) +} + diff --git a/src/MyWebLog.Domain/DataTypes.fs b/src/MyWebLog.Domain/DataTypes.fs new file mode 100644 index 0000000..94bcde9 --- /dev/null +++ b/src/MyWebLog.Domain/DataTypes.fs @@ -0,0 +1,427 @@ +namespace MyWebLog + +open System +open MyWebLog + +/// A category under which a post may be identified +[] +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 +[] +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) +[] +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 +[] +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 +[] +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 +[] +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 () diff --git a/src/MyWebLog.Domain/MyWebLog.Domain.fsproj b/src/MyWebLog.Domain/MyWebLog.Domain.fsproj new file mode 100644 index 0000000..da6624e --- /dev/null +++ b/src/MyWebLog.Domain/MyWebLog.Domain.fsproj @@ -0,0 +1,20 @@ + + + + net6.0 + true + + + + + + + + + + + + + + + diff --git a/src/MyWebLog.Domain/SupportTypes.fs b/src/MyWebLog.Domain/SupportTypes.fs new file mode 100644 index 0000000..6fa4464 --- /dev/null +++ b/src/MyWebLog.Domain/SupportTypes.fs @@ -0,0 +1,483 @@ +namespace MyWebLog + +open System + +/// Support functions for domain definition +[] +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 +[] +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 +[] +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 +[] +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 = + /// The user may administer all aspects of a web log + | Administrator + /// The user is a known user of a web log + | 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 ()) + + diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs new file mode 100644 index 0000000..7f17ea4 --- /dev/null +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -0,0 +1,740 @@ +namespace MyWebLog.ViewModels + +open System +open MyWebLog + +/// Helper functions for view models +[] +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 +[] +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 +[] +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 +[] +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 +[] +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 +[] +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 +[] +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 +[] +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 + + /// 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 +[] +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 +[] +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 +[] +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 +[] +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 +[] +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 +[] +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 + + /// 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 +[] +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 + } + + +[] +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" } diff --git a/src/MyWebLog.Migrate/Program.fs b/src/MyWebLog.Migrate/Program.fs deleted file mode 100644 index 7d6dafd..0000000 --- a/src/MyWebLog.Migrate/Program.fs +++ /dev/null @@ -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("""{ "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(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(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(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(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() |> toTicks - Text = string x.["text"] - }) - .RunResult(conn) - |> ignore) - - Console.WriteLine "Migrating pages..." - - r.Db("MyWebLog").Table(Table.Page) - .RunCursor(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 () |> toTicks - UpdatedOn = x.["lastUpdatedDate"].ToObject () |> toTicks - ShowInPageList = x.["showInPageList"].ToObject() - Text = string x.["text"] - Revisions = [{ AsOf = x.["lastUpdatedDate"].ToObject () |> toTicks - SourceType = RevisionSource.HTML - Text = string x.["text"] - }] - }) - .RunResult(conn) - |> ignore) - - Console.WriteLine "Migrating posts..." - - r.Db("MyWebLog").Table(Table.Post) - .RunCursor(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 () |> toTicks - UpdatedOn = x.["lastUpdatedDate"].ToObject () |> toTicks - Revisions = [{ AsOf = x.["lastUpdatedDate"].ToObject () - |> 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) - - -[] -let main argv = - migr8 () - 0 // return an integer exit code diff --git a/src/MyWebLog.Migrate/project.json b/src/MyWebLog.Migrate/project.json deleted file mode 100644 index 9131f89..0000000 --- a/src/MyWebLog.Migrate/project.json +++ /dev/null @@ -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" - } - } - } -} diff --git a/src/MyWebLog.Tests/MyWebLog.Tests.fs b/src/MyWebLog.Tests/MyWebLog.Tests.fs deleted file mode 100644 index 9210d97..0000000 --- a/src/MyWebLog.Tests/MyWebLog.Tests.fs +++ /dev/null @@ -1,4 +0,0 @@ -namespace MyWebLog.Web - -type Web() = - member this.X = "F#" diff --git a/src/MyWebLog.Tests/MyWebLog.Tests.fsproj b/src/MyWebLog.Tests/MyWebLog.Tests.fsproj deleted file mode 100644 index 74198ff..0000000 --- a/src/MyWebLog.Tests/MyWebLog.Tests.fsproj +++ /dev/null @@ -1,70 +0,0 @@ - - - - - Debug - AnyCPU - 2.0 - 07e60874-6cf5-4d53-aee0-f17ef28228dd - Library - MyWebLog.Tests - MyWebLog.Tests - v4.5.2 - 4.4.0.0 - MyWebLog.Tests - - - true - full - false - false - bin\Debug\ - DEBUG;TRACE - 3 - bin\Debug\MyWebLog.Tests.xml - - - pdbonly - true - true - bin\Release\ - TRACE - 3 - bin\Release\MyWebLog.Tests.xml - - - - - True - - - - - - - - - - 11 - - - - - $(MSBuildExtensionsPath32)\..\Microsoft SDKs\F#\3.0\Framework\v4.0\Microsoft.FSharp.Targets - - - - - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\FSharp\Microsoft.FSharp.Targets - - - - - - diff --git a/src/MyWebLog.sln b/src/MyWebLog.sln new file mode 100644 index 0000000..a594b6e --- /dev/null +++ b/src/MyWebLog.sln @@ -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 diff --git a/src/MyWebLog/Caches.fs b/src/MyWebLog/Caches.fs new file mode 100644 index 0000000..c391216 --- /dev/null +++ b/src/MyWebLog/Caches.fs @@ -0,0 +1,157 @@ +namespace MyWebLog + +open Microsoft.AspNetCore.Http +open MyWebLog.Data + +/// Extension properties on HTTP context for web log +[] +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 () + + +open System.Collections.Concurrent + +/// +/// In-memory cache of web log details +/// +/// This is filled by the middleware via the first request for each host, and can be updated via the web log +/// settings update page +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 () + + /// 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 () + + /// 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 () + + /// 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 () + + /// 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] + } diff --git a/src/MyWebLog/DotLiquidBespoke.fs b/src/MyWebLog/DotLiquidBespoke.fs new file mode 100644 index 0000000..79fc22c --- /dev/null +++ b/src/MyWebLog/DotLiquidBespoke.fs @@ -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 { + "
  • " + text + "
  • " + } + |> 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 $"""""" + + // Theme assets + if assetExists "style.css" webLog then + result.WriteLine $"""{s}""" + if assetExists "favicon.ico" webLog then + result.WriteLine $"""{s}""" + + // RSS feeds and canonical URLs + let feedLink title url = + let escTitle = HttpUtility.HtmlAttributeEncode title + let relUrl = WebLog.relativeUrl webLog (Permalink url) + $"""{s}""" + + if webLog.rss.feedEnabled && getBool "is_home" then + result.WriteLine (feedLink webLog.name webLog.rss.feedName) + result.WriteLine $"""{s}""" + + 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}""" + + if getBool "is_page" then + let page = context.Environments[0].["page"] :?> DisplayPage + let url = WebLog.absoluteUrl webLog (Permalink page.permalink) + result.WriteLine $"""{s}""" + + +/// 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}""" + + +/// 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 { + """" + } + |> 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; typeof; typeof; typeof + typeof; typeof; typeof; typeof + typeof + ] + |> List.iter Template.RegisterFilter + + Template.RegisterTag "page_head" + Template.RegisterTag "page_foot" + Template.RegisterTag "user_links" + + [ // Domain types + typeof; typeof; typeof; typeof; typeof; typeof + // View models + typeof; typeof; typeof; typeof + typeof; typeof; typeof; typeof + typeof; typeof; typeof; typeof + typeof; typeof; typeof; typeof + typeof + // Framework types + typeof; typeof; typeof; typeof + typeof; typeof; typeof + ] + |> List.iter (fun it -> Template.RegisterSafeType (it, [| "*" |])) diff --git a/src/MyWebLog/Handlers/Admin.fs b/src/MyWebLog/Handlers/Admin.fs new file mode 100644 index 0000000..b31c23c --- /dev/null +++ b/src/MyWebLog/Handlers/Admin.fs @@ -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) = 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 () + 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 () + 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 () + 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 () + 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 () + 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 +} diff --git a/src/MyWebLog/Handlers/Error.fs b/src/MyWebLog/Handlers/Error.fs new file mode 100644 index 0000000..0f7157c --- /dev/null +++ b/src/MyWebLog/Handlers/Error.fs @@ -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 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" diff --git a/src/MyWebLog/Handlers/Feed.fs b/src/MyWebLog/Handlers/Feed.fs new file mode 100644 index 0000000..4745ed2 --- /dev/null +++ b/src/MyWebLog/Handlers/Feed.fs @@ -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 +[] +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 "

    " + 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 () + 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 () + 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 +} diff --git a/src/MyWebLog/Handlers/Helpers.fs b/src/MyWebLog/Handlers/Helpers.fs new file mode 100644 index 0000000..35e6d9b --- /dev/null +++ b/src/MyWebLog/Handlers/Helpers.fs @@ -0,0 +1,256 @@ +[] +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 "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 "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 () + 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 () + +/// 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 () + 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 () + 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 () + let log = fac.CreateLogger $"MyWebLog.Handlers.{name}" + log.LogWarning msg + \ No newline at end of file diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs new file mode 100644 index 0000000..6a6ba9e --- /dev/null +++ b/src/MyWebLog/Handlers/Post.fs @@ -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 $""" (Page {pageNbr})""" + 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 $""" (Page {pageNbr})""" + 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 () + 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 () + 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 +} diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs new file mode 100644 index 0000000..0bbbcb7 --- /dev/null +++ b/src/MyWebLog/Handlers/Routes.fs @@ -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 ] diff --git a/src/MyWebLog/Handlers/User.fs b/src/MyWebLog/Handlers/User.fs new file mode 100644 index 0000000..ed08809 --- /dev/null +++ b/src/MyWebLog/Handlers/User.fs @@ -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 () + 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 () + 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 +} diff --git a/src/MyWebLog/Maintenance.fs b/src/MyWebLog/Maintenance.fs new file mode 100644 index 0000000..26a58cc --- /dev/null +++ b/src/MyWebLog/Maintenance.fs @@ -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 () + + 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 = "

    This is your default home page.

    " + revisions = [ + { asOf = DateTime.UtcNow + text = Html "

    This is your default home page.

    " + } + ] + } + + 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 () + + 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 () + 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 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 () + 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 () + 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)" + } + \ No newline at end of file diff --git a/src/MyWebLog/MyWebLog.fsproj b/src/MyWebLog/MyWebLog.fsproj new file mode 100644 index 0000000..8bff1fa --- /dev/null +++ b/src/MyWebLog/MyWebLog.fsproj @@ -0,0 +1,44 @@ + + + + Exe + net6.0 + 3391 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MyWebLog/MyWebLog.xproj b/src/MyWebLog/MyWebLog.xproj deleted file mode 100644 index 4fca1d9..0000000 --- a/src/MyWebLog/MyWebLog.xproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - 14.0 - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - - - - - B9F6DB52-65A1-4C2A-8C97-739E08A1D4FB - MyWebLog - .\obj - .\bin\ - v4.5.2 - - - - 2.0 - - - diff --git a/src/MyWebLog/Program.cs b/src/MyWebLog/Program.cs deleted file mode 100644 index 01ea1cf..0000000 --- a/src/MyWebLog/Program.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace MyWebLog -{ - class Program - { - static void Main(string[] args) - { - App.Run(); - } - } -} diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs new file mode 100644 index 0000000..35adc22 --- /dev/null +++ b/src/MyWebLog/Program.fs @@ -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) = + + /// 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 () + 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> ()) + 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> ()) + else + let log = sp.GetRequiredService> () + 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 + +[] +let rec main args = + + let builder = WebApplication.CreateBuilder(args) + let _ = builder.Services.Configure(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 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 () + builder.Services.AddScoped (fun sp -> + let conn = new SqliteConnection (sql.Conn.ConnectionString) + SQLiteData.setUpConnection conn |> Async.AwaitTask |> Async.RunSynchronously + conn) + |> ignore + builder.Services.AddScoped () |> 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 () + 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 diff --git a/src/MyWebLog/Properties/AssemblyInfo.cs b/src/MyWebLog/Properties/AssemblyInfo.cs deleted file mode 100644 index f1792ff..0000000 --- a/src/MyWebLog/Properties/AssemblyInfo.cs +++ /dev/null @@ -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")] diff --git a/src/MyWebLog/appsettings.json b/src/MyWebLog/appsettings.json new file mode 100644 index 0000000..0e6f2ea --- /dev/null +++ b/src/MyWebLog/appsettings.json @@ -0,0 +1,12 @@ +{ + "RethinkDB": { + "hostname": "data02.bitbadger.solutions", + "database": "myWebLog_dev" + }, + "Generator": "myWebLog 2.0-alpha36", + "Logging": { + "LogLevel": { + "MyWebLog.Handlers": "Debug" + } + } +} diff --git a/src/MyWebLog/config.json b/src/MyWebLog/config.json deleted file mode 100644 index 53897f0..0000000 --- a/src/MyWebLog/config.json +++ /dev/null @@ -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" - } -} diff --git a/src/MyWebLog/project.json b/src/MyWebLog/project.json deleted file mode 100644 index 86ea157..0000000 --- a/src/MyWebLog/project.json +++ /dev/null @@ -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" -} diff --git a/src/MyWebLog/views/admin/admin-layout.html b/src/MyWebLog/views/admin/admin-layout.html deleted file mode 100644 index 7974c7e..0000000 --- a/src/MyWebLog/views/admin/admin-layout.html +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - @Model.PageTitle | @Translate.Admin | @Model.WebLog.Name - - - - - - -
    - -
    -
    - @Each.Messages - @Current.ToDisplay - @EndEach - @Section['Content']; -
    -
    -
    -
    -
    @Model.FooterLogoLight  
    -
    -
    -
    - - - - @Section['Scripts']; - - \ No newline at end of file diff --git a/src/MyWebLog/views/admin/category/edit.html b/src/MyWebLog/views/admin/category/edit.html deleted file mode 100644 index ca39310..0000000 --- a/src/MyWebLog/views/admin/category/edit.html +++ /dev/null @@ -1,55 +0,0 @@ -@Master['admin/admin-layout'] - -@Section['Content'] -
    - @AntiForgeryToken -
    -
    - - @Translate.BackToCategoryList - -
    - - -
    -
    -
    -
    -
    -
    - - -
    -
    - - -
    -
    -
    -
    - - -
    -
    -

    - -

    -
    -
    -
    -@EndSection - -@Section['Scripts'] - -@EndSection diff --git a/src/MyWebLog/views/admin/category/list.html b/src/MyWebLog/views/admin/category/list.html deleted file mode 100644 index 14d7faa..0000000 --- a/src/MyWebLog/views/admin/category/list.html +++ /dev/null @@ -1,51 +0,0 @@ -@Master['admin/admin-layout'] - -@Section['Content'] - -
    - - - - - - - @Each.Categories - - - - - - @EndEach -
    @Translate.Action@Translate.Category@Translate.Description
    - @Translate.Edit   - - @Translate.Delete - - @Current.ListName - @If.HasDescription - @Current.Category.Description.Value - @EndIf - @IfNot.HasDescription -   - @EndIf -
    -
    -
    - @AntiForgeryToken -
    -@EndSection - -@Section['Scripts'] - -@EndSection \ No newline at end of file diff --git a/src/MyWebLog/views/admin/content/admin.css b/src/MyWebLog/views/admin/content/admin.css deleted file mode 100644 index 7a8dc5c..0000000 --- a/src/MyWebLog/views/admin/content/admin.css +++ /dev/null @@ -1,5 +0,0 @@ -footer { - background-color: #808080; - border-top: solid 1px black; - color: white; -} \ No newline at end of file diff --git a/src/MyWebLog/views/admin/content/tinymce-init.js b/src/MyWebLog/views/admin/content/tinymce-init.js deleted file mode 100644 index 48c9339..0000000 --- a/src/MyWebLog/views/admin/content/tinymce-init.js +++ /dev/null @@ -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" -}) \ No newline at end of file diff --git a/src/MyWebLog/views/admin/dashboard.html b/src/MyWebLog/views/admin/dashboard.html deleted file mode 100644 index c4e75a5..0000000 --- a/src/MyWebLog/views/admin/dashboard.html +++ /dev/null @@ -1,31 +0,0 @@ -@Master['admin/admin-layout'] - -@Section['Content'] -
    -
    -

    @Translate.Posts  @Model.Posts

    -

    - @Translate.ListAll -     - @Translate.AddNew -

    -
    -
    -

    @Translate.Pages  @Model.Pages

    -

    - @Translate.ListAll -     - @Translate.AddNew -

    -
    -
    -

    @Translate.Categories  @Model.Categories

    -

    - @Translate.ListAll -     - @Translate.AddNew -

    -
    -
    -
    -@EndSection diff --git a/src/MyWebLog/views/admin/page/edit.html b/src/MyWebLog/views/admin/page/edit.html deleted file mode 100644 index facb824..0000000 --- a/src/MyWebLog/views/admin/page/edit.html +++ /dev/null @@ -1,61 +0,0 @@ -@Master['admin/admin-layout'] - -@Section['Content'] -
    - @AntiForgeryToken -
    -
    - - @Translate.BackToPageList - -
    - - -
    -
    - - -

    @Translate.startingWith //@Model.WebLog.UrlBase/

    -
    - - -
    - -
    -
    -
    -
    -
    @Translate.PageDetails
    -
    - @IfNot.isNew -
    - -

    @Model.PublishedDate
    @Model.PublishedTime

    -
    -
    - -

    @Model.LastUpdatedDate
    @Model.LastUpdatedTime

    -
    - @EndIf -
    - -   -
    -
    -
    -
    -

    -
    -
    -
    -
    -@EndSection - -@Section['Scripts'] - - -@EndSection diff --git a/src/MyWebLog/views/admin/page/list.html b/src/MyWebLog/views/admin/page/list.html deleted file mode 100644 index 7e5f759..0000000 --- a/src/MyWebLog/views/admin/page/list.html +++ /dev/null @@ -1,42 +0,0 @@ -@Master['admin/admin-layout'] - -@Section['Content'] - -
    - - - - - - @Each.Pages - - - - - @EndEach -
    @Translate.Title@Translate.LastUpdated
    - @Current.Page.Title
    - @Translate.View   - @Translate.Edit   - @Translate.Delete -
    @Current.UpdatedDate
    @Translate.at @Current.UpdatedTime
    -
    -
    - @AntiForgeryToken -
    -@EndSection - -@Section['Scripts'] - -@EndSection diff --git a/src/MyWebLog/views/admin/post/edit.html b/src/MyWebLog/views/admin/post/edit.html deleted file mode 100644 index 39963c7..0000000 --- a/src/MyWebLog/views/admin/post/edit.html +++ /dev/null @@ -1,90 +0,0 @@ -@Master['admin/admin-layout'] - -@Section['Content'] -
    - @AntiForgeryToken -
    -
    - - @Translate.BackToPostList - -
    - - -
    -
    - - -

    @Translate.startingWith //@Model.WebLog.UrlBase/

    -
    - - -
    - -
    -
    - - -
    -
    -
    -
    -
    -

    @Translate.PostDetails

    -
    -
    -
    - -

    @Model.Post.Status

    -
    - @If.IsPublished -
    - -

    @Model.PublishedDate
    @Model.PublishedTime

    -
    - @EndIf -
    -
    -
    -
    -

    @Translate.Categories

    -
    -
    - @Each.Categories - @Current.Indent - -   - -
    - @EndEach -
    -
    -
    - @If.IsPublished - - @EndIf - @IfNot.IsPublished -
    - -   -
    - @EndIf -

    - -

    -
    -
    -
    -
    -@EndSection - -@Section['Scripts'] - - -@EndSection diff --git a/src/MyWebLog/views/admin/post/list.html b/src/MyWebLog/views/admin/post/list.html deleted file mode 100644 index ee9cf74..0000000 --- a/src/MyWebLog/views/admin/post/list.html +++ /dev/null @@ -1,49 +0,0 @@ -@Master['admin/admin-layout'] - -@Section['Content'] - -
    - - - - - - - - @Each.Posts - - - - - - - @EndEach -
    @Translate.Date@Translate.Title@Translate.Status@Translate.Tags
    - @Current.PublishedDate
    - @Translate.at @Current.PublishedTime -
    - @Current.Post.Title
    - @Translate.View  |  - @Translate.Edit  |  - @Translate.Delete -
    @Current.Post.Status@Current.Tags
    -
    -
    -
    - @If.HasNewer -

    «  @Translate.NewerPosts

    - @EndIf -
    -
    - @If.HasOlder -

    @Translate.OlderPosts  »

    - @EndIf -
    -
    -@EndSection diff --git a/src/MyWebLog/views/admin/user/log-on.html b/src/MyWebLog/views/admin/user/log-on.html deleted file mode 100644 index 2115f3c..0000000 --- a/src/MyWebLog/views/admin/user/log-on.html +++ /dev/null @@ -1,41 +0,0 @@ -@Master['admin/admin-layout'] - -@Section['Content'] -
    - @AntiForgeryToken - -
    -
    -
    - - -
    -
    -
    -
    -
    -
    -
    - - -
    -
    -
    -
    -
    -

    -
    - -

    -
    -
    -
    -@EndSection - -@Section['Scripts'] - -@EndSection diff --git a/src/MyWebLog/views/themes/default/comment.html b/src/MyWebLog/views/themes/default/comment.html deleted file mode 100644 index 2c7b34b..0000000 --- a/src/MyWebLog/views/themes/default/comment.html +++ /dev/null @@ -1,4 +0,0 @@ -

    - @Model.Commentor    @Model.CommentedOn -

    -@Model.Comment.Text \ No newline at end of file diff --git a/src/MyWebLog/views/themes/default/content/bootstrap-theme.css b/src/MyWebLog/views/themes/default/content/bootstrap-theme.css deleted file mode 100644 index b0fdfcb..0000000 --- a/src/MyWebLog/views/themes/default/content/bootstrap-theme.css +++ /dev/null @@ -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 */ diff --git a/src/MyWebLog/views/themes/default/content/bootstrap-theme.css.map b/src/MyWebLog/views/themes/default/content/bootstrap-theme.css.map deleted file mode 100644 index 5a12d63..0000000 --- a/src/MyWebLog/views/themes/default/content/bootstrap-theme.css.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":["less/theme.less","less/mixins/vendor-prefixes.less","bootstrap-theme.css","less/mixins/gradients.less","less/mixins/reset-filter.less"],"names":[],"mappings":"AAcA;;;;;;EAME,0CAAA;ECgDA,6FAAA;EACQ,qFAAA;EC5DT;AFgBC;;;;;;;;;;;;EC2CA,0DAAA;EACQ,kDAAA;EC7CT;AFVD;;;;;;EAiBI,mBAAA;EECH;AFiCC;;EAEE,wBAAA;EE/BH;AFoCD;EGnDI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EAEA,wHAAA;ECnBF,qEAAA;EJiCA,6BAAA;EACA,uBAAA;EAgC2C,2BAAA;EAA2B,oBAAA;EEzBvE;AFLC;;EAEE,2BAAA;EACA,8BAAA;EEOH;AFJC;;EAEE,2BAAA;EACA,uBAAA;EEMH;AFHC;;;EAGE,2BAAA;EACA,wBAAA;EEKH;AFUD;EGpDI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EAEA,wHAAA;ECnBF,qEAAA;EJiCA,6BAAA;EACA,uBAAA;EEgCD;AF9BC;;EAEE,2BAAA;EACA,8BAAA;EEgCH;AF7BC;;EAEE,2BAAA;EACA,uBAAA;EE+BH;AF5BC;;;EAGE,2BAAA;EACA,wBAAA;EE8BH;AFdD;EGrDI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EAEA,wHAAA;ECnBF,qEAAA;EJiCA,6BAAA;EACA,uBAAA;EEyDD;AFvDC;;EAEE,2BAAA;EACA,8BAAA;EEyDH;AFtDC;;EAEE,2BAAA;EACA,uBAAA;EEwDH;AFrDC;;;EAGE,2BAAA;EACA,wBAAA;EEuDH;AFtCD;EGtDI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EAEA,wHAAA;ECnBF,qEAAA;EJiCA,6BAAA;EACA,uBAAA;EEkFD;AFhFC;;EAEE,2BAAA;EACA,8BAAA;EEkFH;AF/EC;;EAEE,2BAAA;EACA,uBAAA;EEiFH;AF9EC;;;EAGE,2BAAA;EACA,wBAAA;EEgFH;AF9DD;EGvDI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EAEA,wHAAA;ECnBF,qEAAA;EJiCA,6BAAA;EACA,uBAAA;EE2GD;AFzGC;;EAEE,2BAAA;EACA,8BAAA;EE2GH;AFxGC;;EAEE,2BAAA;EACA,uBAAA;EE0GH;AFvGC;;;EAGE,2BAAA;EACA,wBAAA;EEyGH;AFtFD;EGxDI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EAEA,wHAAA;ECnBF,qEAAA;EJiCA,6BAAA;EACA,uBAAA;EEoID;AFlIC;;EAEE,2BAAA;EACA,8BAAA;EEoIH;AFjIC;;EAEE,2BAAA;EACA,uBAAA;EEmIH;AFhIC;;;EAGE,2BAAA;EACA,wBAAA;EEkIH;AFxGD;;EChBE,oDAAA;EACQ,4CAAA;EC4HT;AFnGD;;EGzEI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EHwEF,2BAAA;EEyGD;AFvGD;;;EG9EI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EH8EF,2BAAA;EE6GD;AFpGD;EG3FI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;ECnBF,qEAAA;EJ6GA,oBAAA;EC/CA,6FAAA;EACQ,qFAAA;EC0JT;AF/GD;;EG3FI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EF2CF,0DAAA;EACQ,kDAAA;ECoKT;AF5GD;;EAEE,gDAAA;EE8GD;AF1GD;EG9GI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;ECnBF,qEAAA;EF+OD;AFlHD;;EG9GI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EF2CF,yDAAA;EACQ,iDAAA;EC0LT;AF5HD;;EAYI,2CAAA;EEoHH;AF/GD;;;EAGE,kBAAA;EEiHD;AF5FD;EAfI;;;IAGE,aAAA;IG3IF,0EAAA;IACA,qEAAA;IACA,+FAAA;IAAA,wEAAA;IACA,6BAAA;IACA,wHAAA;ID0PD;EACF;AFxGD;EACE,+CAAA;ECzGA,4FAAA;EACQ,oFAAA;ECoNT;AFhGD;EGpKI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EH4JF,uBAAA;EE4GD;AFvGD;EGrKI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EH4JF,uBAAA;EEoHD;AF9GD;EGtKI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EH4JF,uBAAA;EE4HD;AFrHD;EGvKI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EH4JF,uBAAA;EEoID;AFrHD;EG/KI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EDuSH;AFlHD;EGzLI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;ED8SH;AFxHD;EG1LI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EDqTH;AF9HD;EG3LI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;ED4TH;AFpID;EG5LI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EDmUH;AF1ID;EG7LI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;ED0UH;AF7ID;EGhKI,+MAAA;EACA,0MAAA;EACA,uMAAA;EDgTH;AFzID;EACE,oBAAA;EC5JA,oDAAA;EACQ,4CAAA;ECwST;AF1ID;;;EAGE,+BAAA;EGjNE,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EH+MF,uBAAA;EEgJD;AFrJD;;;EAQI,mBAAA;EEkJH;AFxID;ECjLE,mDAAA;EACQ,2CAAA;EC4TT;AFlID;EG1OI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;ED+WH;AFxID;EG3OI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EDsXH;AF9ID;EG5OI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;ED6XH;AFpJD;EG7OI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EDoYH;AF1JD;EG9OI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;ED2YH;AFhKD;EG/OI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EDkZH;AFhKD;EGtPI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EHoPF,uBAAA;ECzMA,2FAAA;EACQ,mFAAA;ECgXT","file":"bootstrap-theme.css","sourcesContent":["\n//\n// Load core variables and mixins\n// --------------------------------------------------\n\n@import \"variables.less\";\n@import \"mixins.less\";\n\n\n//\n// Buttons\n// --------------------------------------------------\n\n// Common styles\n.btn-default,\n.btn-primary,\n.btn-success,\n.btn-info,\n.btn-warning,\n.btn-danger {\n text-shadow: 0 -1px 0 rgba(0,0,0,.2);\n @shadow: inset 0 1px 0 rgba(255,255,255,.15), 0 1px 1px rgba(0,0,0,.075);\n .box-shadow(@shadow);\n\n // Reset the shadow\n &:active,\n &.active {\n .box-shadow(inset 0 3px 5px rgba(0,0,0,.125));\n }\n\n .badge {\n text-shadow: none;\n }\n}\n\n// Mixin for generating new styles\n.btn-styles(@btn-color: #555) {\n #gradient > .vertical(@start-color: @btn-color; @end-color: darken(@btn-color, 12%));\n .reset-filter(); // Disable gradients for IE9 because filter bleeds through rounded corners; see https://github.com/twbs/bootstrap/issues/10620\n background-repeat: repeat-x;\n border-color: darken(@btn-color, 14%);\n\n &:hover,\n &:focus {\n background-color: darken(@btn-color, 12%);\n background-position: 0 -15px;\n }\n\n &:active,\n &.active {\n background-color: darken(@btn-color, 12%);\n border-color: darken(@btn-color, 14%);\n }\n\n &.disabled,\n &:disabled,\n &[disabled] {\n background-color: darken(@btn-color, 12%);\n background-image: none;\n }\n}\n\n// Common styles\n.btn {\n // Remove the gradient for the pressed/active state\n &:active,\n &.active {\n background-image: none;\n }\n}\n\n// Apply the mixin to the buttons\n.btn-default { .btn-styles(@btn-default-bg); text-shadow: 0 1px 0 #fff; border-color: #ccc; }\n.btn-primary { .btn-styles(@btn-primary-bg); }\n.btn-success { .btn-styles(@btn-success-bg); }\n.btn-info { .btn-styles(@btn-info-bg); }\n.btn-warning { .btn-styles(@btn-warning-bg); }\n.btn-danger { .btn-styles(@btn-danger-bg); }\n\n\n//\n// Images\n// --------------------------------------------------\n\n.thumbnail,\n.img-thumbnail {\n .box-shadow(0 1px 2px rgba(0,0,0,.075));\n}\n\n\n//\n// Dropdowns\n// --------------------------------------------------\n\n.dropdown-menu > li > a:hover,\n.dropdown-menu > li > a:focus {\n #gradient > .vertical(@start-color: @dropdown-link-hover-bg; @end-color: darken(@dropdown-link-hover-bg, 5%));\n background-color: darken(@dropdown-link-hover-bg, 5%);\n}\n.dropdown-menu > .active > a,\n.dropdown-menu > .active > a:hover,\n.dropdown-menu > .active > a:focus {\n #gradient > .vertical(@start-color: @dropdown-link-active-bg; @end-color: darken(@dropdown-link-active-bg, 5%));\n background-color: darken(@dropdown-link-active-bg, 5%);\n}\n\n\n//\n// Navbar\n// --------------------------------------------------\n\n// Default navbar\n.navbar-default {\n #gradient > .vertical(@start-color: lighten(@navbar-default-bg, 10%); @end-color: @navbar-default-bg);\n .reset-filter(); // Remove gradient in IE<10 to fix bug where dropdowns don't get triggered\n border-radius: @navbar-border-radius;\n @shadow: inset 0 1px 0 rgba(255,255,255,.15), 0 1px 5px rgba(0,0,0,.075);\n .box-shadow(@shadow);\n\n .navbar-nav > .open > a,\n .navbar-nav > .active > a {\n #gradient > .vertical(@start-color: darken(@navbar-default-link-active-bg, 5%); @end-color: darken(@navbar-default-link-active-bg, 2%));\n .box-shadow(inset 0 3px 9px rgba(0,0,0,.075));\n }\n}\n.navbar-brand,\n.navbar-nav > li > a {\n text-shadow: 0 1px 0 rgba(255,255,255,.25);\n}\n\n// Inverted navbar\n.navbar-inverse {\n #gradient > .vertical(@start-color: lighten(@navbar-inverse-bg, 10%); @end-color: @navbar-inverse-bg);\n .reset-filter(); // Remove gradient in IE<10 to fix bug where dropdowns don't get triggered; see https://github.com/twbs/bootstrap/issues/10257\n\n .navbar-nav > .open > a,\n .navbar-nav > .active > a {\n #gradient > .vertical(@start-color: @navbar-inverse-link-active-bg; @end-color: lighten(@navbar-inverse-link-active-bg, 2.5%));\n .box-shadow(inset 0 3px 9px rgba(0,0,0,.25));\n }\n\n .navbar-brand,\n .navbar-nav > li > a {\n text-shadow: 0 -1px 0 rgba(0,0,0,.25);\n }\n}\n\n// Undo rounded corners in static and fixed navbars\n.navbar-static-top,\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n border-radius: 0;\n}\n\n// Fix active state of dropdown items in collapsed mode\n@media (max-width: @grid-float-breakpoint-max) {\n .navbar .navbar-nav .open .dropdown-menu > .active > a {\n &,\n &:hover,\n &:focus {\n color: #fff;\n #gradient > .vertical(@start-color: @dropdown-link-active-bg; @end-color: darken(@dropdown-link-active-bg, 5%));\n }\n }\n}\n\n\n//\n// Alerts\n// --------------------------------------------------\n\n// Common styles\n.alert {\n text-shadow: 0 1px 0 rgba(255,255,255,.2);\n @shadow: inset 0 1px 0 rgba(255,255,255,.25), 0 1px 2px rgba(0,0,0,.05);\n .box-shadow(@shadow);\n}\n\n// Mixin for generating new styles\n.alert-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 7.5%));\n border-color: darken(@color, 15%);\n}\n\n// Apply the mixin to the alerts\n.alert-success { .alert-styles(@alert-success-bg); }\n.alert-info { .alert-styles(@alert-info-bg); }\n.alert-warning { .alert-styles(@alert-warning-bg); }\n.alert-danger { .alert-styles(@alert-danger-bg); }\n\n\n//\n// Progress bars\n// --------------------------------------------------\n\n// Give the progress background some depth\n.progress {\n #gradient > .vertical(@start-color: darken(@progress-bg, 4%); @end-color: @progress-bg)\n}\n\n// Mixin for generating new styles\n.progress-bar-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 10%));\n}\n\n// Apply the mixin to the progress bars\n.progress-bar { .progress-bar-styles(@progress-bar-bg); }\n.progress-bar-success { .progress-bar-styles(@progress-bar-success-bg); }\n.progress-bar-info { .progress-bar-styles(@progress-bar-info-bg); }\n.progress-bar-warning { .progress-bar-styles(@progress-bar-warning-bg); }\n.progress-bar-danger { .progress-bar-styles(@progress-bar-danger-bg); }\n\n// Reset the striped class because our mixins don't do multiple gradients and\n// the above custom styles override the new `.progress-bar-striped` in v3.2.0.\n.progress-bar-striped {\n #gradient > .striped();\n}\n\n\n//\n// List groups\n// --------------------------------------------------\n\n.list-group {\n border-radius: @border-radius-base;\n .box-shadow(0 1px 2px rgba(0,0,0,.075));\n}\n.list-group-item.active,\n.list-group-item.active:hover,\n.list-group-item.active:focus {\n text-shadow: 0 -1px 0 darken(@list-group-active-bg, 10%);\n #gradient > .vertical(@start-color: @list-group-active-bg; @end-color: darken(@list-group-active-bg, 7.5%));\n border-color: darken(@list-group-active-border, 7.5%);\n\n .badge {\n text-shadow: none;\n }\n}\n\n\n//\n// Panels\n// --------------------------------------------------\n\n// Common styles\n.panel {\n .box-shadow(0 1px 2px rgba(0,0,0,.05));\n}\n\n// Mixin for generating new styles\n.panel-heading-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 5%));\n}\n\n// Apply the mixin to the panel headings only\n.panel-default > .panel-heading { .panel-heading-styles(@panel-default-heading-bg); }\n.panel-primary > .panel-heading { .panel-heading-styles(@panel-primary-heading-bg); }\n.panel-success > .panel-heading { .panel-heading-styles(@panel-success-heading-bg); }\n.panel-info > .panel-heading { .panel-heading-styles(@panel-info-heading-bg); }\n.panel-warning > .panel-heading { .panel-heading-styles(@panel-warning-heading-bg); }\n.panel-danger > .panel-heading { .panel-heading-styles(@panel-danger-heading-bg); }\n\n\n//\n// Wells\n// --------------------------------------------------\n\n.well {\n #gradient > .vertical(@start-color: darken(@well-bg, 5%); @end-color: @well-bg);\n border-color: darken(@well-bg, 10%);\n @shadow: inset 0 1px 3px rgba(0,0,0,.05), 0 1px 0 rgba(255,255,255,.1);\n .box-shadow(@shadow);\n}\n","// Vendor Prefixes\n//\n// All vendor mixins are deprecated as of v3.2.0 due to the introduction of\n// Autoprefixer in our Gruntfile. They will be removed in v4.\n\n// - Animations\n// - Backface visibility\n// - Box shadow\n// - Box sizing\n// - Content columns\n// - Hyphens\n// - Placeholder text\n// - Transformations\n// - Transitions\n// - User Select\n\n\n// Animations\n.animation(@animation) {\n -webkit-animation: @animation;\n -o-animation: @animation;\n animation: @animation;\n}\n.animation-name(@name) {\n -webkit-animation-name: @name;\n animation-name: @name;\n}\n.animation-duration(@duration) {\n -webkit-animation-duration: @duration;\n animation-duration: @duration;\n}\n.animation-timing-function(@timing-function) {\n -webkit-animation-timing-function: @timing-function;\n animation-timing-function: @timing-function;\n}\n.animation-delay(@delay) {\n -webkit-animation-delay: @delay;\n animation-delay: @delay;\n}\n.animation-iteration-count(@iteration-count) {\n -webkit-animation-iteration-count: @iteration-count;\n animation-iteration-count: @iteration-count;\n}\n.animation-direction(@direction) {\n -webkit-animation-direction: @direction;\n animation-direction: @direction;\n}\n.animation-fill-mode(@fill-mode) {\n -webkit-animation-fill-mode: @fill-mode;\n animation-fill-mode: @fill-mode;\n}\n\n// Backface visibility\n// Prevent browsers from flickering when using CSS 3D transforms.\n// Default value is `visible`, but can be changed to `hidden`\n\n.backface-visibility(@visibility){\n -webkit-backface-visibility: @visibility;\n -moz-backface-visibility: @visibility;\n backface-visibility: @visibility;\n}\n\n// Drop shadows\n//\n// Note: Deprecated `.box-shadow()` as of v3.1.0 since all of Bootstrap's\n// supported browsers that have box shadow capabilities now support it.\n\n.box-shadow(@shadow) {\n -webkit-box-shadow: @shadow; // iOS <4.3 & Android <4.1\n box-shadow: @shadow;\n}\n\n// Box sizing\n.box-sizing(@boxmodel) {\n -webkit-box-sizing: @boxmodel;\n -moz-box-sizing: @boxmodel;\n box-sizing: @boxmodel;\n}\n\n// CSS3 Content Columns\n.content-columns(@column-count; @column-gap: @grid-gutter-width) {\n -webkit-column-count: @column-count;\n -moz-column-count: @column-count;\n column-count: @column-count;\n -webkit-column-gap: @column-gap;\n -moz-column-gap: @column-gap;\n column-gap: @column-gap;\n}\n\n// Optional hyphenation\n.hyphens(@mode: auto) {\n word-wrap: break-word;\n -webkit-hyphens: @mode;\n -moz-hyphens: @mode;\n -ms-hyphens: @mode; // IE10+\n -o-hyphens: @mode;\n hyphens: @mode;\n}\n\n// Placeholder text\n.placeholder(@color: @input-color-placeholder) {\n // Firefox\n &::-moz-placeholder {\n color: @color;\n opacity: 1; // Override Firefox's unusual default opacity; see https://github.com/twbs/bootstrap/pull/11526\n }\n &:-ms-input-placeholder { color: @color; } // Internet Explorer 10+\n &::-webkit-input-placeholder { color: @color; } // Safari and Chrome\n}\n\n// Transformations\n.scale(@ratio) {\n -webkit-transform: scale(@ratio);\n -ms-transform: scale(@ratio); // IE9 only\n -o-transform: scale(@ratio);\n transform: scale(@ratio);\n}\n.scale(@ratioX; @ratioY) {\n -webkit-transform: scale(@ratioX, @ratioY);\n -ms-transform: scale(@ratioX, @ratioY); // IE9 only\n -o-transform: scale(@ratioX, @ratioY);\n transform: scale(@ratioX, @ratioY);\n}\n.scaleX(@ratio) {\n -webkit-transform: scaleX(@ratio);\n -ms-transform: scaleX(@ratio); // IE9 only\n -o-transform: scaleX(@ratio);\n transform: scaleX(@ratio);\n}\n.scaleY(@ratio) {\n -webkit-transform: scaleY(@ratio);\n -ms-transform: scaleY(@ratio); // IE9 only\n -o-transform: scaleY(@ratio);\n transform: scaleY(@ratio);\n}\n.skew(@x; @y) {\n -webkit-transform: skewX(@x) skewY(@y);\n -ms-transform: skewX(@x) skewY(@y); // See https://github.com/twbs/bootstrap/issues/4885; IE9+\n -o-transform: skewX(@x) skewY(@y);\n transform: skewX(@x) skewY(@y);\n}\n.translate(@x; @y) {\n -webkit-transform: translate(@x, @y);\n -ms-transform: translate(@x, @y); // IE9 only\n -o-transform: translate(@x, @y);\n transform: translate(@x, @y);\n}\n.translate3d(@x; @y; @z) {\n -webkit-transform: translate3d(@x, @y, @z);\n transform: translate3d(@x, @y, @z);\n}\n.rotate(@degrees) {\n -webkit-transform: rotate(@degrees);\n -ms-transform: rotate(@degrees); // IE9 only\n -o-transform: rotate(@degrees);\n transform: rotate(@degrees);\n}\n.rotateX(@degrees) {\n -webkit-transform: rotateX(@degrees);\n -ms-transform: rotateX(@degrees); // IE9 only\n -o-transform: rotateX(@degrees);\n transform: rotateX(@degrees);\n}\n.rotateY(@degrees) {\n -webkit-transform: rotateY(@degrees);\n -ms-transform: rotateY(@degrees); // IE9 only\n -o-transform: rotateY(@degrees);\n transform: rotateY(@degrees);\n}\n.perspective(@perspective) {\n -webkit-perspective: @perspective;\n -moz-perspective: @perspective;\n perspective: @perspective;\n}\n.perspective-origin(@perspective) {\n -webkit-perspective-origin: @perspective;\n -moz-perspective-origin: @perspective;\n perspective-origin: @perspective;\n}\n.transform-origin(@origin) {\n -webkit-transform-origin: @origin;\n -moz-transform-origin: @origin;\n -ms-transform-origin: @origin; // IE9 only\n transform-origin: @origin;\n}\n\n\n// Transitions\n\n.transition(@transition) {\n -webkit-transition: @transition;\n -o-transition: @transition;\n transition: @transition;\n}\n.transition-property(@transition-property) {\n -webkit-transition-property: @transition-property;\n transition-property: @transition-property;\n}\n.transition-delay(@transition-delay) {\n -webkit-transition-delay: @transition-delay;\n transition-delay: @transition-delay;\n}\n.transition-duration(@transition-duration) {\n -webkit-transition-duration: @transition-duration;\n transition-duration: @transition-duration;\n}\n.transition-timing-function(@timing-function) {\n -webkit-transition-timing-function: @timing-function;\n transition-timing-function: @timing-function;\n}\n.transition-transform(@transition) {\n -webkit-transition: -webkit-transform @transition;\n -moz-transition: -moz-transform @transition;\n -o-transition: -o-transform @transition;\n transition: transform @transition;\n}\n\n\n// User select\n// For selecting text on the page\n\n.user-select(@select) {\n -webkit-user-select: @select;\n -moz-user-select: @select;\n -ms-user-select: @select; // IE10+\n user-select: @select;\n}\n",".btn-default,\n.btn-primary,\n.btn-success,\n.btn-info,\n.btn-warning,\n.btn-danger {\n text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.2);\n -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075);\n}\n.btn-default:active,\n.btn-primary:active,\n.btn-success:active,\n.btn-info:active,\n.btn-warning:active,\n.btn-danger:active,\n.btn-default.active,\n.btn-primary.active,\n.btn-success.active,\n.btn-info.active,\n.btn-warning.active,\n.btn-danger.active {\n -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n}\n.btn-default .badge,\n.btn-primary .badge,\n.btn-success .badge,\n.btn-info .badge,\n.btn-warning .badge,\n.btn-danger .badge {\n text-shadow: none;\n}\n.btn:active,\n.btn.active {\n background-image: none;\n}\n.btn-default {\n background-image: -webkit-linear-gradient(top, #ffffff 0%, #e0e0e0 100%);\n background-image: -o-linear-gradient(top, #ffffff 0%, #e0e0e0 100%);\n background-image: linear-gradient(to bottom, #ffffff 0%, #e0e0e0 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n background-repeat: repeat-x;\n border-color: #dbdbdb;\n text-shadow: 0 1px 0 #fff;\n border-color: #ccc;\n}\n.btn-default:hover,\n.btn-default:focus {\n background-color: #e0e0e0;\n background-position: 0 -15px;\n}\n.btn-default:active,\n.btn-default.active {\n background-color: #e0e0e0;\n border-color: #dbdbdb;\n}\n.btn-default.disabled,\n.btn-default:disabled,\n.btn-default[disabled] {\n background-color: #e0e0e0;\n background-image: none;\n}\n.btn-primary {\n background-image: -webkit-linear-gradient(top, #337ab7 0%, #265a88 100%);\n background-image: -o-linear-gradient(top, #337ab7 0%, #265a88 100%);\n background-image: linear-gradient(to bottom, #337ab7 0%, #265a88 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n background-repeat: repeat-x;\n border-color: #245580;\n}\n.btn-primary:hover,\n.btn-primary:focus {\n background-color: #265a88;\n background-position: 0 -15px;\n}\n.btn-primary:active,\n.btn-primary.active {\n background-color: #265a88;\n border-color: #245580;\n}\n.btn-primary.disabled,\n.btn-primary:disabled,\n.btn-primary[disabled] {\n background-color: #265a88;\n background-image: none;\n}\n.btn-success {\n background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%);\n background-image: -o-linear-gradient(top, #5cb85c 0%, #419641 100%);\n background-image: linear-gradient(to bottom, #5cb85c 0%, #419641 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n background-repeat: repeat-x;\n border-color: #3e8f3e;\n}\n.btn-success:hover,\n.btn-success:focus {\n background-color: #419641;\n background-position: 0 -15px;\n}\n.btn-success:active,\n.btn-success.active {\n background-color: #419641;\n border-color: #3e8f3e;\n}\n.btn-success.disabled,\n.btn-success:disabled,\n.btn-success[disabled] {\n background-color: #419641;\n background-image: none;\n}\n.btn-info {\n background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);\n background-image: -o-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);\n background-image: linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n background-repeat: repeat-x;\n border-color: #28a4c9;\n}\n.btn-info:hover,\n.btn-info:focus {\n background-color: #2aabd2;\n background-position: 0 -15px;\n}\n.btn-info:active,\n.btn-info.active {\n background-color: #2aabd2;\n border-color: #28a4c9;\n}\n.btn-info.disabled,\n.btn-info:disabled,\n.btn-info[disabled] {\n background-color: #2aabd2;\n background-image: none;\n}\n.btn-warning {\n background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);\n background-image: -o-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);\n background-image: linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n background-repeat: repeat-x;\n border-color: #e38d13;\n}\n.btn-warning:hover,\n.btn-warning:focus {\n background-color: #eb9316;\n background-position: 0 -15px;\n}\n.btn-warning:active,\n.btn-warning.active {\n background-color: #eb9316;\n border-color: #e38d13;\n}\n.btn-warning.disabled,\n.btn-warning:disabled,\n.btn-warning[disabled] {\n background-color: #eb9316;\n background-image: none;\n}\n.btn-danger {\n background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%);\n background-image: -o-linear-gradient(top, #d9534f 0%, #c12e2a 100%);\n background-image: linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n background-repeat: repeat-x;\n border-color: #b92c28;\n}\n.btn-danger:hover,\n.btn-danger:focus {\n background-color: #c12e2a;\n background-position: 0 -15px;\n}\n.btn-danger:active,\n.btn-danger.active {\n background-color: #c12e2a;\n border-color: #b92c28;\n}\n.btn-danger.disabled,\n.btn-danger:disabled,\n.btn-danger[disabled] {\n background-color: #c12e2a;\n background-image: none;\n}\n.thumbnail,\n.img-thumbnail {\n -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);\n}\n.dropdown-menu > li > a:hover,\n.dropdown-menu > li > a:focus {\n background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);\n background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);\n background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);\n background-color: #e8e8e8;\n}\n.dropdown-menu > .active > a,\n.dropdown-menu > .active > a:hover,\n.dropdown-menu > .active > a:focus {\n background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);\n background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);\n background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);\n background-color: #2e6da4;\n}\n.navbar-default {\n background-image: -webkit-linear-gradient(top, #ffffff 0%, #f8f8f8 100%);\n background-image: -o-linear-gradient(top, #ffffff 0%, #f8f8f8 100%);\n background-image: linear-gradient(to bottom, #ffffff 0%, #f8f8f8 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 5px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 5px rgba(0, 0, 0, 0.075);\n}\n.navbar-default .navbar-nav > .open > a,\n.navbar-default .navbar-nav > .active > a {\n background-image: -webkit-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);\n background-image: -o-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);\n background-image: linear-gradient(to bottom, #dbdbdb 0%, #e2e2e2 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);\n -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.075);\n}\n.navbar-brand,\n.navbar-nav > li > a {\n text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25);\n}\n.navbar-inverse {\n background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222222 100%);\n background-image: -o-linear-gradient(top, #3c3c3c 0%, #222222 100%);\n background-image: linear-gradient(to bottom, #3c3c3c 0%, #222222 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n}\n.navbar-inverse .navbar-nav > .open > a,\n.navbar-inverse .navbar-nav > .active > a {\n background-image: -webkit-linear-gradient(top, #080808 0%, #0f0f0f 100%);\n background-image: -o-linear-gradient(top, #080808 0%, #0f0f0f 100%);\n background-image: linear-gradient(to bottom, #080808 0%, #0f0f0f 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);\n -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.25);\n box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.25);\n}\n.navbar-inverse .navbar-brand,\n.navbar-inverse .navbar-nav > li > a {\n text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);\n}\n.navbar-static-top,\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n border-radius: 0;\n}\n@media (max-width: 767px) {\n .navbar .navbar-nav .open .dropdown-menu > .active > a,\n .navbar .navbar-nav .open .dropdown-menu > .active > a:hover,\n .navbar .navbar-nav .open .dropdown-menu > .active > a:focus {\n color: #fff;\n background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);\n background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);\n background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);\n }\n}\n.alert {\n text-shadow: 0 1px 0 rgba(255, 255, 255, 0.2);\n -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 1px 2px rgba(0, 0, 0, 0.05);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 1px 2px rgba(0, 0, 0, 0.05);\n}\n.alert-success {\n background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);\n background-image: -o-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);\n background-image: linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);\n border-color: #b2dba1;\n}\n.alert-info {\n background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%);\n background-image: -o-linear-gradient(top, #d9edf7 0%, #b9def0 100%);\n background-image: linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);\n border-color: #9acfea;\n}\n.alert-warning {\n background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);\n background-image: -o-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);\n background-image: linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);\n border-color: #f5e79e;\n}\n.alert-danger {\n background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);\n background-image: -o-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);\n background-image: linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);\n border-color: #dca7a7;\n}\n.progress {\n background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);\n background-image: -o-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);\n background-image: linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);\n}\n.progress-bar {\n background-image: -webkit-linear-gradient(top, #337ab7 0%, #286090 100%);\n background-image: -o-linear-gradient(top, #337ab7 0%, #286090 100%);\n background-image: linear-gradient(to bottom, #337ab7 0%, #286090 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);\n}\n.progress-bar-success {\n background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%);\n background-image: -o-linear-gradient(top, #5cb85c 0%, #449d44 100%);\n background-image: linear-gradient(to bottom, #5cb85c 0%, #449d44 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);\n}\n.progress-bar-info {\n background-image: -webkit-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);\n background-image: -o-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);\n background-image: linear-gradient(to bottom, #5bc0de 0%, #31b0d5 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);\n}\n.progress-bar-warning {\n background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);\n background-image: -o-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);\n background-image: linear-gradient(to bottom, #f0ad4e 0%, #ec971f 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);\n}\n.progress-bar-danger {\n background-image: -webkit-linear-gradient(top, #d9534f 0%, #c9302c 100%);\n background-image: -o-linear-gradient(top, #d9534f 0%, #c9302c 100%);\n background-image: linear-gradient(to bottom, #d9534f 0%, #c9302c 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);\n}\n.progress-bar-striped {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n}\n.list-group {\n border-radius: 4px;\n -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);\n}\n.list-group-item.active,\n.list-group-item.active:hover,\n.list-group-item.active:focus {\n text-shadow: 0 -1px 0 #286090;\n background-image: -webkit-linear-gradient(top, #337ab7 0%, #2b669a 100%);\n background-image: -o-linear-gradient(top, #337ab7 0%, #2b669a 100%);\n background-image: linear-gradient(to bottom, #337ab7 0%, #2b669a 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);\n border-color: #2b669a;\n}\n.list-group-item.active .badge,\n.list-group-item.active:hover .badge,\n.list-group-item.active:focus .badge {\n text-shadow: none;\n}\n.panel {\n -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);\n}\n.panel-default > .panel-heading {\n background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);\n background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);\n background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);\n}\n.panel-primary > .panel-heading {\n background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);\n background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);\n background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);\n}\n.panel-success > .panel-heading {\n background-image: -webkit-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);\n background-image: -o-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);\n background-image: linear-gradient(to bottom, #dff0d8 0%, #d0e9c6 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);\n}\n.panel-info > .panel-heading {\n background-image: -webkit-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);\n background-image: -o-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);\n background-image: linear-gradient(to bottom, #d9edf7 0%, #c4e3f3 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);\n}\n.panel-warning > .panel-heading {\n background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);\n background-image: -o-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);\n background-image: linear-gradient(to bottom, #fcf8e3 0%, #faf2cc 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);\n}\n.panel-danger > .panel-heading {\n background-image: -webkit-linear-gradient(top, #f2dede 0%, #ebcccc 100%);\n background-image: -o-linear-gradient(top, #f2dede 0%, #ebcccc 100%);\n background-image: linear-gradient(to bottom, #f2dede 0%, #ebcccc 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);\n}\n.well {\n background-image: -webkit-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);\n background-image: -o-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);\n background-image: linear-gradient(to bottom, #e8e8e8 0%, #f5f5f5 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);\n border-color: #dcdcdc;\n -webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 0 rgba(255, 255, 255, 0.1);\n box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 0 rgba(255, 255, 255, 0.1);\n}\n/*# sourceMappingURL=bootstrap-theme.css.map */","// Gradients\n\n#gradient {\n\n // Horizontal gradient, from left to right\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .horizontal(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Opera 12\n background-image: linear-gradient(to right, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n // Vertical gradient, from top to bottom\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .vertical(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Opera 12\n background-image: linear-gradient(to bottom, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n .directional(@start-color: #555; @end-color: #333; @deg: 45deg) {\n background-repeat: repeat-x;\n background-image: -webkit-linear-gradient(@deg, @start-color, @end-color); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(@deg, @start-color, @end-color); // Opera 12\n background-image: linear-gradient(@deg, @start-color, @end-color); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n }\n .horizontal-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: -o-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(to right, @start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .vertical-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: -o-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .radial(@inner-color: #555; @outer-color: #333) {\n background-image: -webkit-radial-gradient(circle, @inner-color, @outer-color);\n background-image: radial-gradient(circle, @inner-color, @outer-color);\n background-repeat: no-repeat;\n }\n .striped(@color: rgba(255,255,255,.15); @angle: 45deg) {\n background-image: -webkit-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n }\n}\n","// Reset filters for IE\n//\n// When you need to remove a gradient background, do not forget to use this to reset\n// the IE filter for IE9 and below.\n\n.reset-filter() {\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(enabled = false)\"));\n}\n"]} \ No newline at end of file diff --git a/src/MyWebLog/views/themes/default/content/bootstrap-theme.min.css b/src/MyWebLog/views/themes/default/content/bootstrap-theme.min.css deleted file mode 100644 index cefa3d1..0000000 --- a/src/MyWebLog/views/themes/default/content/bootstrap-theme.min.css +++ /dev/null @@ -1,5 +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-danger,.btn-default,.btn-info,.btn-primary,.btn-success,.btn-warning{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-danger.active,.btn-danger:active,.btn-default.active,.btn-default:active,.btn-info.active,.btn-info:active,.btn-primary.active,.btn-primary:active,.btn-success.active,.btn-success:active,.btn-warning.active,.btn-warning: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-danger .badge,.btn-default .badge,.btn-info .badge,.btn-primary .badge,.btn-success .badge,.btn-warning .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:focus,.btn-default:hover{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:focus,.btn-primary:hover{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:focus,.btn-success:hover{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:focus,.btn-info:hover{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:focus,.btn-warning:hover{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:focus,.btn-danger:hover{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}.img-thumbnail,.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:focus,.dropdown-menu>li>a:hover{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:focus,.dropdown-menu>.active>a:hover{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>.active>a,.navbar-default .navbar-nav>.open>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>.active>a,.navbar-inverse .navbar-nav>.open>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-fixed-bottom,.navbar-fixed-top,.navbar-static-top{border-radius:0}@media (max-width:767px){.navbar .navbar-nav .open .dropdown-menu>.active>a,.navbar .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar .navbar-nav .open .dropdown-menu>.active>a:hover{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:focus,.list-group-item.active:hover{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:focus .badge,.list-group-item.active:hover .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)} \ No newline at end of file diff --git a/src/MyWebLog/views/themes/default/footer.html b/src/MyWebLog/views/themes/default/footer.html deleted file mode 100644 index ec3f017..0000000 --- a/src/MyWebLog/views/themes/default/footer.html +++ /dev/null @@ -1,10 +0,0 @@ -
    -
    -
    -
    -
    - @Model.FooterLogoDark -
    -
    -
    -
    \ No newline at end of file diff --git a/src/MyWebLog/views/themes/default/index-content.html b/src/MyWebLog/views/themes/default/index-content.html deleted file mode 100644 index 18a7f6b..0000000 --- a/src/MyWebLog/views/themes/default/index-content.html +++ /dev/null @@ -1,43 +0,0 @@ -@Each.Messages - @Current.ToDisplay -@EndEach -@If.SubTitle.IsSome -

    - @Model.SubTitle -

    -@EndIf -@Each.Posts -
    -
    -
    -

    - @Current.Post.Title -

    -

    - @Current.PublishedDate   - @Current.PublishedTime   - @Current.CommentCount -

    - @Current.Post.Text -
    -
    -
    -
    -@EndEach -
    -
    - @If.HasNewer -

    - @Translate.NewerPosts -

    - @EndIf -
    -
    - @If.HasOlder -

    - @Translate.OlderPosts -

    - @EndIf -
    -
    \ No newline at end of file diff --git a/src/MyWebLog/views/themes/default/index.html b/src/MyWebLog/views/themes/default/index.html deleted file mode 100644 index 6ef4c3a..0000000 --- a/src/MyWebLog/views/themes/default/index.html +++ /dev/null @@ -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 diff --git a/src/MyWebLog/views/themes/default/layout.html b/src/MyWebLog/views/themes/default/layout.html deleted file mode 100644 index ed29d2c..0000000 --- a/src/MyWebLog/views/themes/default/layout.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - @Model.DisplayPageTitle - - - - - - @Section['Head']; - - -
    - -
    -
    - @Section['Content']; -
    - @Section['Footer']; - - - @Section['Scripts']; - - \ No newline at end of file diff --git a/src/MyWebLog/views/themes/default/page-content.html b/src/MyWebLog/views/themes/default/page-content.html deleted file mode 100644 index 731e6b8..0000000 --- a/src/MyWebLog/views/themes/default/page-content.html +++ /dev/null @@ -1,4 +0,0 @@ -
    -

    @Model.Page.Title

    - @Model.Page.Text -
    \ No newline at end of file diff --git a/src/MyWebLog/views/themes/default/page.html b/src/MyWebLog/views/themes/default/page.html deleted file mode 100644 index d3d60c1..0000000 --- a/src/MyWebLog/views/themes/default/page.html +++ /dev/null @@ -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 diff --git a/src/MyWebLog/views/themes/default/single-content.html b/src/MyWebLog/views/themes/default/single-content.html deleted file mode 100644 index c2e3f94..0000000 --- a/src/MyWebLog/views/themes/default/single-content.html +++ /dev/null @@ -1,67 +0,0 @@ -
    -
    -

    @Model.Post.Title

    -
    -
    -
    -

    - @Model.PublishedDate   - @Model.PublishedTime   - @Model.CommentCount       - @Each.Post.Categories - - - @Current.Name -     - - @EndEach -

    -
    -
    -
    -
    @Model.Post.Text
    -
    - @If.HasTags -
    -
    - @Each.Tags - - - @Current.Item1 -     - - @EndEach -
    -
    - @EndIf -
    -
    -

    -
    -
    -
    - @Each.Comments - @Partial['themes/default/comment', @Current] - @EndEach -
    -
    -
    -

    -
    -
    -
    - @If.HasNewer - - «  @Model.NewerPost.Value.Title - - @EndIf -
    -
    - @If.HasOlder - - @Model.OlderPost.Value.Title  » - - @EndIf -
    -
    \ No newline at end of file diff --git a/src/MyWebLog/views/themes/default/single.html b/src/MyWebLog/views/themes/default/single.html deleted file mode 100644 index 894c3b7..0000000 --- a/src/MyWebLog/views/themes/default/single.html +++ /dev/null @@ -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 diff --git a/src/Settings.FSharpLint b/src/Settings.FSharpLint deleted file mode 100644 index d8e7530..0000000 --- a/src/Settings.FSharpLint +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src/admin-theme/_layout.liquid b/src/admin-theme/_layout.liquid new file mode 100644 index 0000000..46737cb --- /dev/null +++ b/src/admin-theme/_layout.liquid @@ -0,0 +1,54 @@ +
    + +
    +
    +
    + {% for msg in messages %} + + {% endfor %} +
    + {{ content }} +
    +
    +
    +
    +
    + myWebLog +
    +
    +
    +
    diff --git a/src/admin-theme/category-edit.liquid b/src/admin-theme/category-edit.liquid new file mode 100644 index 0000000..cb402fa --- /dev/null +++ b/src/admin-theme/category-edit.liquid @@ -0,0 +1,54 @@ +
    +
    {{ page_title }}
    +
    + + +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + + Cancel +
    +
    +
    +
    diff --git a/src/admin-theme/category-list-body.liquid b/src/admin-theme/category-list-body.liquid new file mode 100644 index 0000000..6f050a4 --- /dev/null +++ b/src/admin-theme/category-list-body.liquid @@ -0,0 +1,46 @@ +
    + +
    + {%- 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 -%} +
    +
    + {%- if cat.parent_names %} + {% for name in cat.parent_names %}{{ name }} ⟩ {% endfor %} + {%- endif %} + {{ cat.name }}
    + + {%- if cat.post_count > 0 %} + + View {{ cat.post_count }} Post{% unless cat.post_count == 1 %}s{% endunless -%} + + + {%- endif %} + {%- capture cat_edit %}admin/category/{{ cat.id }}/edit{% endcapture -%} + + Edit + + + {%- capture cat_del %}admin/category/{{ cat.id }}/delete{% endcapture -%} + {%- capture cat_del_link %}{{ cat_del | relative_link }}{% endcapture -%} + + Delete + + +
    +
    + {%- if cat.description %}{{ cat.description.value }}{% else %}none{% endif %} +
    +
    + {%- endfor %} + {%- else -%} +
    +
    This web log has no categores defined
    +
    + {%- endif %} +
    diff --git a/src/admin-theme/category-list.liquid b/src/admin-theme/category-list.liquid new file mode 100644 index 0000000..1cfce71 --- /dev/null +++ b/src/admin-theme/category-list.liquid @@ -0,0 +1,16 @@ +

    {{ page_title }}

    +
    + + Add a New Category + +
    + {%- 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" -%} +
    +
    Category; Description
    +
    Description
    +
    +
    + {{ category_list }} +
    diff --git a/src/admin-theme/custom-feed-edit.liquid b/src/admin-theme/custom-feed-edit.liquid new file mode 100644 index 0000000..c20c29f --- /dev/null +++ b/src/admin-theme/custom-feed-edit.liquid @@ -0,0 +1,208 @@ +

    {{ page_title }}

    +
    +
    + + + {%- assign typ = model.source_type -%} +
    + +
    +
    +
    + Identification +
    +
    +
    + + + Appended to {{ web_log.url_base }}/ +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    + Feed Source +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    + Podcast Settings +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + + + + + iTunes Category / Subcategory List + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + + For iTunes, must match registered e-mail +
    +
    +
    +
    + + + Optional; blank for no default +
    +
    +
    +
    + + + Relative URL will be appended to {{ web_log.url_base }}/ +
    +
    +
    +
    +
    +
    + + + Displayed in podcast directories +
    +
    +
    +
    +
    +
    + + + Optional; prepended to episode media file if present +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    diff --git a/src/admin-theme/dashboard.liquid b/src/admin-theme/dashboard.liquid new file mode 100644 index 0000000..c7046ea --- /dev/null +++ b/src/admin-theme/dashboard.liquid @@ -0,0 +1,51 @@ +

    {{ web_log.name }} • Dashboard

    + diff --git a/src/admin-theme/layout-bare.liquid b/src/admin-theme/layout-bare.liquid new file mode 100644 index 0000000..a343dd8 --- /dev/null +++ b/src/admin-theme/layout-bare.liquid @@ -0,0 +1,5 @@ + + + +{{ content }} + diff --git a/src/admin-theme/layout-partial.liquid b/src/admin-theme/layout-partial.liquid new file mode 100644 index 0000000..4b273a6 --- /dev/null +++ b/src/admin-theme/layout-partial.liquid @@ -0,0 +1,10 @@ + + + + {{ page_title | strip_html }} « Admin « {{ web_log.name | strip_html }} + + + {% include_template "_layout" %} + + + diff --git a/src/admin-theme/layout.liquid b/src/admin-theme/layout.liquid new file mode 100644 index 0000000..a556a5b --- /dev/null +++ b/src/admin-theme/layout.liquid @@ -0,0 +1,32 @@ + + + + + + {{ page_title | strip_html }} « Admin « {{ web_log.name | strip_html }} + + + + + {% include_template "_layout" %} + + {{ htmx_script }} + + + + + diff --git a/src/admin-theme/log-on.liquid b/src/admin-theme/log-on.liquid new file mode 100644 index 0000000..ef5960a --- /dev/null +++ b/src/admin-theme/log-on.liquid @@ -0,0 +1,30 @@ +

    Log On to {{ web_log.name }}

    +
    +
    + + {% if model.return_to %} + + {% endif %} +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    diff --git a/src/admin-theme/page-edit.liquid b/src/admin-theme/page-edit.liquid new file mode 100644 index 0000000..e5db77e --- /dev/null +++ b/src/admin-theme/page-edit.liquid @@ -0,0 +1,105 @@ +

    {{ page_title }}

    +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + + {%- if model.page_id != "new" %} + {%- capture perm_edit %}admin/page/{{ model.page_id }}/permalinks{% endcapture -%} + Manage Permalinks + {% endif -%} +
    +
    +     + + + + +
    +
    + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + + Metadata + + +
    +
    + {%- for meta in metadata %} +
    +
    + +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    + {% endfor -%} +
    + + +
    +
    +
    +
    +
    +
    +
    diff --git a/src/admin-theme/page-list.liquid b/src/admin-theme/page-list.liquid new file mode 100644 index 0000000..5e48d5a --- /dev/null +++ b/src/admin-theme/page-list.liquid @@ -0,0 +1,70 @@ +

    {{ page_title }}

    +
    + Create a New Page + {%- assign page_count = pages | size -%} + {%- assign title_col = "col-12 col-md-5" -%} + {%- assign link_col = "col-12 col-md-5" -%} + {%- assign upd8_col = "col-12 col-md-2" -%} +
    + +
    +
    + TitlePage +
    + +
    Updated
    +
    + {% if page_count > 0 %} + {% for pg in pages -%} +
    +
    + {{ pg.title }} + {%- if pg.is_default %}   HOME PAGE{% endif -%} + {%- if pg.show_in_page_list %}   IN PAGE LIST {% endif -%}
    + + {%- capture pg_link %}{% unless pg.is_default %}{{ pg.permalink }}{% endunless %}{% endcapture -%} + View Page + + Edit + + {%- capture pg_del %}admin/page/{{ pg.id }}/delete{% endcapture -%} + {%- capture pg_del_link %}{{ pg_del | relative_link }}{% endcapture -%} + + Delete + + +
    + +
    + Updated {{ pg.updated_on | date: "MMMM d, yyyy" }} + {{ pg.updated_on | date: "MMMM d, yyyy" }} +
    +
    + {%- endfor %} + {% else %} +
    +
    This web log has no pages
    +
    + {% endif %} +
    + {% if page_nbr > 1 or page_count == 25 %} +
    +
    + {% if page_nbr > 1 %} + {%- capture prev_link %}admin/pages{{ prev_page }}{% endcapture -%} +

    « Previous

    + {% endif %} +
    +
    + {% if page_count == 25 %} + {%- capture next_link %}admin/pages{{ next_page }}{% endcapture -%} +

    Next »

    + {% endif %} +
    +
    + {% endif %} +
    diff --git a/src/admin-theme/permalinks.liquid b/src/admin-theme/permalinks.liquid new file mode 100644 index 0000000..9b82d8b --- /dev/null +++ b/src/admin-theme/permalinks.liquid @@ -0,0 +1,59 @@ +

    {{ page_title }}

    +
    + {%- capture form_action %}admin/{{ model.entity }}/permalinks{% endcapture -%} +
    + + +
    +
    +
    +

    + {{ model.current_title }}
    + + {{ model.current_permalink }}
    + {%- capture back_link %}admin/{{ model.entity }}/{{ model.id }}/edit{% endcapture -%} + « Back to Edit {{ model.entity | capitalize }} +
    +

    +
    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    +
    diff --git a/src/admin-theme/post-edit.liquid b/src/admin-theme/post-edit.liquid new file mode 100644 index 0000000..d36161e --- /dev/null +++ b/src/admin-theme/post-edit.liquid @@ -0,0 +1,157 @@ +

    {{ page_title }}

    +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + + {%- if model.post_id != "new" %} + {%- capture perm_edit %}admin/post/{{ model.post_id }}/permalinks{% endcapture -%} + Manage Permalinks + {% endif -%} +
    +
    +     + + + + +
    +
    + +
    +
    + + +
    comma-delimited
    +
    + {% if model.status == "Draft" %} +
    + + +
    + {% endif %} + +
    +
    + + Metadata + + +
    + {%- unless model.meta_names contains "episode_media_file" %} + + {%- endunless %} +
    + {%- for meta in metadata %} +
    +
    + +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    + {% endfor -%} +
    + + +
    +
    + {% if model.status == "Published" %} +
    + Maintenance +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + {% endif %} +
    +
    +
    + + +
    +
    + Categories + {% for cat in categories %} +
    + + +
    + {% endfor %} +
    +
    +
    +
    +
    +
    diff --git a/src/admin-theme/post-list.liquid b/src/admin-theme/post-list.liquid new file mode 100644 index 0000000..722c1f8 --- /dev/null +++ b/src/admin-theme/post-list.liquid @@ -0,0 +1,100 @@ +

    {{ page_title }}

    +
    + Write a New Post +
    + + {%- assign post_count = model.posts | size -%} + {%- assign date_col = "col-xs-12 col-md-3 col-lg-2" -%} + {%- assign title_col = "col-xs-12 col-md-7 col-lg-6 col-xl-5 col-xxl-4" -%} + {%- assign author_col = "col-xs-12 col-md-2 col-lg-1" -%} + {%- assign tag_col = "col-lg-3 col-xl-4 col-xxl-5 d-none d-lg-inline-block" -%} +
    +
    + PostDate +
    +
    Title
    +
    Author
    +
    Tags
    +
    + {%- if post_count > 0 %} + {% for post in model.posts -%} +
    +
    + + {%- if post.published_on -%} + Published {{ post.published_on | date: "MMMM d, yyyy" }} + {%- else -%} + Not Published + {%- endif -%} + {%- if post.published_on != post.updated_on -%} + (Updated {{ post.updated_on | date: "MMMM d, yyyy" }}) + {%- endif %} + + + {%- if post.published_on -%} + {{ post.published_on | date: "MMMM d, yyyy" }} + {%- else -%} + Not Published + {%- endif -%} + {%- if post.published_on != post.updated_on %}
    + {{ post.updated_on | date: "MMMM d, yyyy" }} + {%- endif %} +
    +
    +
    + {%- assign media = post.metadata | value: "episode_media_file" -%} + {%- unless media == "-- episode_media_file not found --" -%} + Episode + {%- endunless -%} + {{ post.title }}
    + + View Post + + Edit + + {%- capture post_del %}admin/post/{{ post.id }}/delete{% endcapture -%} + {%- capture post_del_link %}{{ post_del | relative_link }}{% endcapture -%} + + Delete + + +
    +
    + {%- assign tag_count = post.tags | size -%} + + Authored by {{ model.authors | value: post.author_id }} | + {% if tag_count == 0 -%} + No + {%- else -%} + {{ tag_count }} + {%- endif %} Tag{% unless tag_count == 1 %}s{% endunless %} + + {{ model.authors | value: post.author_id }} +
    +
    + {{ post.tags | join: ", " }} +
    +
    + {%- endfor %} + {% else %} +
    +
    This web log has no posts
    +
    + {% endif %} +
    + {% if model.newer_link or model.older_link %} +
    +
    + {% if model.newer_link %} +

    « Newer Posts

    + {% endif %} +
    +
    + {% if model.older_link %} +

    Older Posts »

    + {% endif %} +
    +
    + {% endif %} +
    diff --git a/src/admin-theme/rss-settings.liquid b/src/admin-theme/rss-settings.liquid new file mode 100644 index 0000000..65ef133 --- /dev/null +++ b/src/admin-theme/rss-settings.liquid @@ -0,0 +1,113 @@ +

    {{ page_title }}

    +
    +
    + +
    +
    +
    +
    + Feeds Enabled +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    + + + Default is feed.xml +
    +
    +
    +
    + + + Set to “0” to use “Posts per Page” setting ({{ web_log.posts_per_page }}) +
    +
    +
    +
    + + + + Can be a + + Creative Commons license string + + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +

    Custom Feeds

    + + Add a New Custom Feed + +
    + {%- assign source_col = "col-12 col-md-6" -%} + {%- assign path_col = "col-12 col-md-6" -%} + +
    +
    + FeedSource +
    +
    Relative Path
    +
    + {%- assign feed_count = custom_feeds | size -%} + {% if feed_count > 0 %} + {% for feed in custom_feeds %} +
    +
    + {{ feed.source }} + {%- if feed.is_podcast %}   PODCAST{% endif %}
    + + View Feed + + {%- capture feed_edit %}admin/settings/rss/{{ feed.id }}/edit{% endcapture -%} + Edit + + {%- capture feed_del %}admin/settings/rss/{{ feed.id }}/delete{% endcapture -%} + {%- capture feed_del_link %}{{ feed_del | relative_link }}{% endcapture -%} + + Delete + + +
    +
    + Served at {{ feed.path }} + {{ feed.path }} +
    +
    + {% endfor %} + {% else %} + + No custom feeds defined + + {% endif %} +
    +
    diff --git a/src/admin-theme/settings.liquid b/src/admin-theme/settings.liquid new file mode 100644 index 0000000..3c49924 --- /dev/null +++ b/src/admin-theme/settings.liquid @@ -0,0 +1,80 @@ +

    {{ web_log.name }} Settings

    +

    + Other Settings: Tag Mappings • + RSS Settings +

    +
    +
    + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    + + What is this? + +
    +
    +
    +
    + +
    +
    +
    +
    +
    diff --git a/src/admin-theme/tag-mapping-edit.liquid b/src/admin-theme/tag-mapping-edit.liquid new file mode 100644 index 0000000..ca84bbf --- /dev/null +++ b/src/admin-theme/tag-mapping-edit.liquid @@ -0,0 +1,30 @@ +
    {{ page_title }}
    +
    + + +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + + + Cancel + +
    +
    +
    diff --git a/src/admin-theme/tag-mapping-list-body.liquid b/src/admin-theme/tag-mapping-list-body.liquid new file mode 100644 index 0000000..3b7544d --- /dev/null +++ b/src/admin-theme/tag-mapping-list-body.liquid @@ -0,0 +1,34 @@ +
    + +
    + {%- assign map_count = mappings | size -%} + {% if map_count > 0 -%} + {% for map in mappings -%} + {%- assign map_id = mapping_ids | value: map.tag -%} +
    +
    + {{ map.tag }}
    + + {%- capture map_edit %}admin/settings/tag-mapping/{{ map_id }}/edit{% endcapture -%} + + Edit + + + {%- capture map_del %}admin/settings/tag-mapping/{{ map_id }}/delete{% endcapture -%} + {%- capture map_del_link %}{{ map_del | relative_link }}{% endcapture -%} + + Delete + + +
    +
    {{ map.url_value }}
    +
    + {%- endfor %} + {%- else -%} +
    +
    This web log has no tag mappings
    +
    + {%- endif %} +
    diff --git a/src/admin-theme/tag-mapping-list.liquid b/src/admin-theme/tag-mapping-list.liquid new file mode 100644 index 0000000..0b583c4 --- /dev/null +++ b/src/admin-theme/tag-mapping-list.liquid @@ -0,0 +1,14 @@ +

    {{ page_title }}

    + diff --git a/src/admin-theme/upload-theme.liquid b/src/admin-theme/upload-theme.liquid new file mode 100644 index 0000000..5066cdc --- /dev/null +++ b/src/admin-theme/upload-theme.liquid @@ -0,0 +1,26 @@ +

    Upload a Theme

    +
    +
    + +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + +
    +
    +
    +
    diff --git a/src/admin-theme/user-edit.liquid b/src/admin-theme/user-edit.liquid new file mode 100644 index 0000000..a06623e --- /dev/null +++ b/src/admin-theme/user-edit.liquid @@ -0,0 +1,64 @@ +

    {{ page_title }}

    +
    +
    + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + Change Password +
    +
    +

    Optional; leave blank to keep your current password

    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    diff --git a/src/admin-theme/version.txt b/src/admin-theme/version.txt new file mode 100644 index 0000000..f5c60da --- /dev/null +++ b/src/admin-theme/version.txt @@ -0,0 +1,2 @@ +myWebLog Admin +2.0.0-alpha34 \ No newline at end of file diff --git a/src/admin-theme/wwwroot/admin.css b/src/admin-theme/wwwroot/admin.css new file mode 100644 index 0000000..3802723 --- /dev/null +++ b/src/admin-theme/wwwroot/admin.css @@ -0,0 +1,87 @@ +:root { + --dark-gray: #212529; + --light-accent: rgba(0, 0, 0, .075); +} +html { + background-color: var(--dark-gray); +} +body { + scrollbar-width: thin; + scrollbar-color: var(--dark-gray) gray; + padding-top: 3rem; + padding-bottom: 2rem; + min-height: 100vh; +} +body::-webkit-scrollbar { + width: 12px; +} +body::-webkit-scrollbar-track { + background: gray; +} +body::-webkit-scrollbar-thumb { + background-color: var(--dark-gray); + border-radius: 20px; + border: 3px solid gray; +} +header nav { + z-index: 9; +} +footer { + background-color: #808080; + border-top: solid 1px black; + color: white; +} +.messages { + max-width: 60rem; + margin: auto; +} +.action-button-column { + width: 1rem; + white-space: nowrap; +} +fieldset { + border: solid 1px lightgray; + border-radius: .5rem; + padding: 0 1rem 1rem; +} +legend { + float: unset; + width: unset; +} +textarea { + font-family: monospace; + font-size: .8rem !important; +} +#text { + min-height: 50vh; +} +.no-wrap { + white-space: nowrap; +} +a:link, a:visited { + text-decoration: none; +} +a:link:hover, a:visited:hover { + text-decoration: underline; +} +a.btn:link:hover, a.btn:visited:hover { + text-decoration: none; +} +a.text-danger:link:hover, a.text-danger:visited:hover { + text-decoration: none; + background-color: var(--bs-danger); + border-radius: 0.25rem; + color: white !important; +} +.mwl-table-heading { + font-size: 1.1rem; + font-weight: bold; + border-bottom: solid 1px var(--bs-dark); +} +.mwl-table-detail { + border-bottom: solid 1px var(--light-accent); +} +.mwl-table-detail:hover { + background-color: var(--light-accent); + color: var(--dark-gray); +} diff --git a/src/admin-theme/wwwroot/admin.js b/src/admin-theme/wwwroot/admin.js new file mode 100644 index 0000000..3122671 --- /dev/null +++ b/src/admin-theme/wwwroot/admin.js @@ -0,0 +1,322 @@ +const Admin = { + /** The next index for a metadata item */ + nextMetaIndex : 0, + + /** The next index for a permalink */ + nextPermalink : 0, + + /** + * Set the next meta item index + * @param idx The index to set + */ + setNextMetaIndex(idx) { + this.nextMetaIndex = idx + }, + + /** + * Set the next permalink index + * @param idx The index to set + */ + setPermalinkIndex(idx) { + this.nextPermalink = idx + }, + + /** + * Create a metadata remove button + * @returns {HTMLDivElement} The column with the remove button + */ + createMetaRemoveColumn() { + const removeBtn = document.createElement("button") + removeBtn.type = "button" + removeBtn.className = "btn btn-sm btn-danger" + removeBtn.innerHTML = "−" + removeBtn.setAttribute("onclick", `Admin.removeMetaItem(${this.nextMetaIndex})`) + + const removeCol = document.createElement("div") + removeCol.className = "col-1 text-center align-self-center" + removeCol.appendChild(removeBtn) + + return removeCol + }, + + /** + * Create a metadata name field + * @returns {HTMLInputElement} The name input element + */ + createMetaNameField() { + const nameField = document.createElement("input") + + nameField.type = "text" + nameField.name = "metaNames" + nameField.id = `metaNames_${this.nextMetaIndex}` + nameField.className = "form-control" + nameField.placeholder = "Name" + + return nameField + }, + + /** + * Create a metadata name column using the given name input field + * @param {HTMLInputElement} field The name field for the column + * @returns {HTMLDivElement} The name column + */ + createMetaNameColumn(field) { + const nameLabel = document.createElement("label") + nameLabel.htmlFor = field.id + nameLabel.innerText = field.placeholder + + const nameFloat = document.createElement("div") + nameFloat.className = "form-floating" + nameFloat.appendChild(field) + nameFloat.appendChild(nameLabel) + + const nameCol = document.createElement("div") + nameCol.className = "col-3" + nameCol.appendChild(nameFloat) + + return nameCol + }, + + /** + * Create a metadata value field + * @returns {HTMLInputElement} The metadata value field + */ + createMetaValueField() { + const valueField = document.createElement("input") + + valueField.type = "text" + valueField.name = "metaValues" + valueField.id = `metaValues_${this.nextMetaIndex}` + valueField.className = "form-control" + valueField.placeholder = "Value" + + return valueField + }, + + /** + * Create a metadata value column using the given input field + * @param {HTMLInputElement} field The metadata value input field + * @param {string|undefined} hintText Text to be added below the field + * @returns {HTMLDivElement} The value column + */ + createMetaValueColumn(field, hintText) { + const valueLabel = document.createElement("label") + valueLabel.htmlFor = field.id + valueLabel.innerText = field.placeholder + + const valueFloat = document.createElement("div") + valueFloat.className = "form-floating" + valueFloat.appendChild(field) + valueFloat.appendChild(valueLabel) + + if (hintText) { + const valueHint = document.createElement("div") + valueHint.className = "form-text" + valueHint.innerText = hintText + valueFloat.appendChild(valueHint) + } + + const valueCol = document.createElement("div") + valueCol.className = "col-8" + valueCol.appendChild(valueFloat) + + return valueCol + }, + + /** + * Construct and add a metadata item row + * @param {HTMLDivElement} removeCol The column with the remove button + * @param {HTMLDivElement} nameCol The column with the name field + * @param {HTMLDivElement} valueCol The column with the value field + */ + createMetaRow(removeCol, nameCol, valueCol) { + const newRow = document.createElement("div") + newRow.className = "row mb-3" + newRow.id = `meta_${this.nextMetaIndex}` + newRow.appendChild(removeCol) + newRow.appendChild(nameCol) + newRow.appendChild(valueCol) + + document.getElementById("metaItems").appendChild(newRow) + this.nextMetaIndex++ + }, + + /** + * Add a new row for metadata entry + */ + addMetaItem() { + const nameField = this.createMetaNameField() + + this.createMetaRow( + this.createMetaRemoveColumn(), + this.createMetaNameColumn(nameField), + this.createMetaValueColumn(this.createMetaValueField(), undefined)) + + document.getElementById(nameField.id).focus() + }, + + /** + * Add a new row for a permalink + */ + addPermalink() { + // Remove button + const removeBtn = document.createElement("button") + removeBtn.type = "button" + removeBtn.className = "btn btn-sm btn-danger" + removeBtn.innerHTML = "−" + removeBtn.setAttribute("onclick", `Admin.removePermalink(${this.nextPermalink})`) + + const removeCol = document.createElement("div") + removeCol.className = "col-1 text-center align-self-center" + removeCol.appendChild(removeBtn) + + // Link + const linkField = document.createElement("input") + linkField.type = "text" + linkField.name = "prior" + linkField.id = `prior_${this.nextPermalink}` + linkField.className = "form-control" + linkField.placeholder = "Link" + + const linkLabel = document.createElement("label") + linkLabel.htmlFor = linkField.id + linkLabel.innerText = linkField.placeholder + + const linkFloat = document.createElement("div") + linkFloat.className = "form-floating" + linkFloat.appendChild(linkField) + linkFloat.appendChild(linkLabel) + + const linkCol = document.createElement("div") + linkCol.className = "col-11" + linkCol.appendChild(linkFloat) + + // Put it all together + const newRow = document.createElement("div") + newRow.className = "row mb-3" + newRow.id = `meta_${this.nextPermalink}` + newRow.appendChild(removeCol) + newRow.appendChild(linkCol) + + document.getElementById("permalinks").appendChild(newRow) + document.getElementById(linkField.id).focus() + this.nextPermalink++ + }, + + /** + * Add podcast episode metadata fields + */ + addPodcastFields() { + [ [ "episode_media_file", true, "required; relative URL will be appended to configured base media path" ], + [ "episode_media_length", true, "required; file size in bytes" ], + [ "episode_duration", false, "suggested; format is HH:MM:SS" ], + [ "episode_media_type", false, "optional; blank uses podcast default" ], + [ "episode_image", false, "optional; relative URLs are served from this web log" ], + [ "episode_subtitle", false, "optional" ], + [ "episode_explicit", false, "optional; blank uses podcast default"] + ].forEach(([fieldName, isRequired, hintText]) => { + const nameField = this.createMetaNameField() + nameField.value = fieldName + nameField.readOnly = true + + const valueField = this.createMetaValueField() + valueField.required = isRequired + + this.createMetaRow( + this.createMetaRemoveColumn(), + this.createMetaNameColumn(nameField), + this.createMetaValueColumn(valueField, hintText)) + }) + document.getElementById("addPodcastFieldButton").remove() + }, + + /** + * Check to enable or disable podcast fields + */ + checkPodcast() { + document.getElementById("podcastFields").disabled = !document.getElementById("isPodcast").checked + }, + + /** + * Toggle the source of a custom RSS feed + * @param source The source that was selected + */ + customFeedBy(source) { + const categoryInput = document.getElementById("sourceValueCat") + const tagInput = document.getElementById("sourceValueTag") + if (source === "category") { + tagInput.value = "" + tagInput.disabled = true + categoryInput.disabled = false + } else { + categoryInput.selectedIndex = -1 + categoryInput.disabled = true + tagInput.disabled = false + } + }, + + /** + * Remove a metadata item + * @param idx The index of the metadata item to remove + */ + removeMetaItem(idx) { + document.getElementById(`meta_${idx}`).remove() + }, + + /** + * Remove a permalink + * @param idx The index of the permalink to remove + */ + removePermalink(idx) { + document.getElementById(`link_${idx}`).remove() + }, + + /** + * Show messages that may have come with an htmx response + * @param messages The messages from the response + */ + showMessage(messages) { + const msgs = messages.split(", ") + msgs.forEach(msg => { + const parts = msg.split("|||") + if (parts.length < 2) return + + const msgDiv = document.createElement("div") + msgDiv.className = `alert alert-${parts[0]} alert-dismissible fade show` + msgDiv.setAttribute("role", "alert") + msgDiv.innerHTML = parts[1] + + const closeBtn = document.createElement("button") + closeBtn.type = "button" + closeBtn.className = "btn-close" + closeBtn.setAttribute("data-bs-dismiss", "alert") + closeBtn.setAttribute("aria-label", "Close") + msgDiv.appendChild(closeBtn) + + if (parts.length === 3) { + msgDiv.innerHTML += `
    ${parts[2]}` + } + document.getElementById("msgContainer").appendChild(msgDiv) + }) + }, + + /** + * Set all "success" alerts to close after 4 seconds + */ + dismissSuccesses() { + [...document.querySelectorAll(".alert-success")].forEach(alert => { + setTimeout(() => { + (bootstrap.Alert.getInstance(alert) ?? new bootstrap.Alert(alert)).close() + }, 4000) + }) + } +} + +htmx.on("htmx:afterOnLoad", function (evt) { + const hdrs = evt.detail.xhr.getAllResponseHeaders() + // Show messages if there were any in the response + if (hdrs.indexOf("x-message") >= 0) { + Admin.showMessage(evt.detail.xhr.getResponseHeader("x-message")) + Admin.dismissSuccesses() + } +}) \ No newline at end of file diff --git a/src/MyWebLog/content/logo-dark.png b/src/admin-theme/wwwroot/logo-dark.png similarity index 100% rename from src/MyWebLog/content/logo-dark.png rename to src/admin-theme/wwwroot/logo-dark.png diff --git a/src/MyWebLog/content/logo-light.png b/src/admin-theme/wwwroot/logo-light.png similarity index 100% rename from src/MyWebLog/content/logo-light.png rename to src/admin-theme/wwwroot/logo-light.png diff --git a/src/default-theme/index.liquid b/src/default-theme/index.liquid new file mode 100644 index 0000000..f59b409 --- /dev/null +++ b/src/default-theme/index.liquid @@ -0,0 +1,56 @@ +{%- if is_category or is_tag %} +

    {{ page_title }}

    + {%- if is_category %} + {%- assign cat = categories | where: "slug", slug | first -%} + {%- if cat.description %}

    {{ cat.description.value }}

    {% endif -%} + {%- endif %} +{%- endif %} +
    + {% for post in model.posts %} +
    +

    + + {{ post.title }} + +

    +

    + Published on {{ post.published_on | date: "MMMM d, yyyy" }} + at {{ post.published_on | date: "h:mmtt" | downcase }} + by {{ model.authors | value: post.author_id }} +

    + {{ post.text }} + {%- assign category_count = post.category_ids | size -%} + {%- assign tag_count = post.tags | size -%} + {% if category_count > 0 or tag_count > 0 %} +
    +

    + {%- if category_count > 0 -%} + Categorized under: + {% for cat in post.category_ids -%} + {%- assign this_cat = categories | where: "id", cat | first -%} + {{ this_cat.name }}{% unless forloop.last %}, {% endunless %} + {%- assign cat_names = this_cat.name | concat: cat_names -%} + {%- endfor -%} + {%- assign cat_names = "" -%} +
    + {% endif -%} + {%- if tag_count > 0 %} + Tagged: {{ post.tags | join: ", " }} + {% endif -%} +

    +
    + {% endif %} +
    +
    + {% endfor %} +
    + diff --git a/src/default-theme/layout.liquid b/src/default-theme/layout.liquid new file mode 100644 index 0000000..9e6760b --- /dev/null +++ b/src/default-theme/layout.liquid @@ -0,0 +1,62 @@ + + + + + + + {{ page_title | strip_html }}{% if page_title %} « {% endif %}{{ web_log.name | strip_html }} + {% page_head -%} + + +
    + +
    +
    + {% if messages %} +
    + {% for msg in messages %} + + {% endfor %} +
    + {% endif %} + {{ content }} +
    +
    +
    +
    + myWebLog +
    +
    + + + diff --git a/src/default-theme/single-page.liquid b/src/default-theme/single-page.liquid new file mode 100644 index 0000000..8509f15 --- /dev/null +++ b/src/default-theme/single-page.liquid @@ -0,0 +1,4 @@ +

    {{ page.title }}

    +
    + {{ page.text }} +
    diff --git a/src/default-theme/single-post.liquid b/src/default-theme/single-post.liquid new file mode 100644 index 0000000..b2643fb --- /dev/null +++ b/src/default-theme/single-post.liquid @@ -0,0 +1,62 @@ +{%- assign post = model.posts | first -%} +

    {{ post.title }}

    +

    + {% if post.published_on -%} + Published {{ post.published_on | date: "dddd, MMMM d, yyyy" }} + at {{ post.published_on | date: "h:mm tt" | downcase }} + {%- else -%} + **DRAFT** + {% endif %} + by {{ model.authors | value: post.author_id }} + {% if logged_on %} • Edit Post {% endif %} +

    +
    +
    +
    {{ post.text }}
    + {%- assign cat_count = post.category_ids | size -%} + {% if cat_count > 0 -%} +

    + Categorized under + {% for cat_id in post.category_ids -%} + {% assign cat = categories | where: "id", cat_id | first %} + + + {{ cat.name }} + + + {% unless forloop.last %} • {% endunless %} + {%- endfor %} +

    + {%- endif %} + {%- assign tag_count = post.tags | size -%} + {% if tag_count > 0 -%} +

    + Tagged + {% for tag in post.tags %} + + + + {% unless forloop.last %} • {% endunless %} + {%- endfor %} +

    + {%- endif %} +
    +
    + +
    +
    diff --git a/src/default-theme/version.txt b/src/default-theme/version.txt new file mode 100644 index 0000000..986b14d --- /dev/null +++ b/src/default-theme/version.txt @@ -0,0 +1,2 @@ +myWebLog Default Theme +2.0.0-alpha36 \ No newline at end of file diff --git a/src/default-theme/wwwroot/style.css b/src/default-theme/wwwroot/style.css new file mode 100644 index 0000000..3e3c297 --- /dev/null +++ b/src/default-theme/wwwroot/style.css @@ -0,0 +1,22 @@ +.messages { + max-width: 60rem; + margin: auto; +} +blockquote { + border-left: solid 4px lightgray; + padding-left: 1rem; +} +.item-meta { + font-size: 1.1rem; + font-weight: normal;; +} +.item-meta::before { + content: "»"; + vertical-align: text-top; +} +a:link, a:visited { + text-decoration: none; +} +a:hover { + text-decoration: underline; +} diff --git a/src/global.json b/src/global.json deleted file mode 100644 index f0dd758..0000000 --- a/src/global.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "projects":[ - "MyWebLog", - "MyWebLog.App" - ] -} \ No newline at end of file diff --git a/src/myWebLog.sln b/src/myWebLog.sln deleted file mode 100644 index b16adba..0000000 --- a/src/myWebLog.sln +++ /dev/null @@ -1,43 +0,0 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25420.1 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "MyWebLog.App", "MyWebLog.App\MyWebLog.App.xproj", "{9CEA3A8B-E8AA-44E6-9F5F-2095CEED54EB}" -EndProject -Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "MyWebLog", "MyWebLog\MyWebLog.xproj", "{B9F6DB52-65A1-4C2A-8C97-739E08A1D4FB}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {A87F3CF5-2189-442B-8ACF-929F5153AC22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A87F3CF5-2189-442B-8ACF-929F5153AC22}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A87F3CF5-2189-442B-8ACF-929F5153AC22}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A87F3CF5-2189-442B-8ACF-929F5153AC22}.Release|Any CPU.Build.0 = Release|Any CPU - {D6C2BE5E-883A-4F34-9905-B730543CA380}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D6C2BE5E-883A-4F34-9905-B730543CA380}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D6C2BE5E-883A-4F34-9905-B730543CA380}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D6C2BE5E-883A-4F34-9905-B730543CA380}.Release|Any CPU.Build.0 = Release|Any CPU - {29F6EDA3-4F43-4BB3-9C63-AE238A9B7F12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {29F6EDA3-4F43-4BB3-9C63-AE238A9B7F12}.Debug|Any CPU.Build.0 = Debug|Any CPU - {29F6EDA3-4F43-4BB3-9C63-AE238A9B7F12}.Release|Any CPU.ActiveCfg = Release|Any CPU - {29F6EDA3-4F43-4BB3-9C63-AE238A9B7F12}.Release|Any CPU.Build.0 = Release|Any CPU - {9CEA3A8B-E8AA-44E6-9F5F-2095CEED54EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9CEA3A8B-E8AA-44E6-9F5F-2095CEED54EB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9CEA3A8B-E8AA-44E6-9F5F-2095CEED54EB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9CEA3A8B-E8AA-44E6-9F5F-2095CEED54EB}.Release|Any CPU.Build.0 = Release|Any CPU - {B9F6DB52-65A1-4C2A-8C97-739E08A1D4FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B9F6DB52-65A1-4C2A-8C97-739E08A1D4FB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B9F6DB52-65A1-4C2A-8C97-739E08A1D4FB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B9F6DB52-65A1-4C2A-8C97-739E08A1D4FB}.Release|Any CPU.Build.0 = Release|Any CPU - {A12EA8DA-88BC-4447-90CB-A0E2DCC37523}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A12EA8DA-88BC-4447-90CB-A0E2DCC37523}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A12EA8DA-88BC-4447-90CB-A0E2DCC37523}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A12EA8DA-88BC-4447-90CB-A0E2DCC37523}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection -EndGlobal