From 5b27ea5cd2bf6834cc7049585db7beba3ee69cbb Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 8 Jul 2023 20:36:49 -0400 Subject: [PATCH 001/123] Add canonical domain support (#37) - Update deps --- .gitignore | 1 + src/MyWebLog.Data/MyWebLog.Data.fsproj | 12 ++++++++---- src/MyWebLog.Domain/MyWebLog.Domain.fsproj | 6 +++--- src/MyWebLog/MyWebLog.fsproj | 12 +++++++----- src/MyWebLog/Program.fs | 6 ++++++ 5 files changed, 25 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 170e429..4a0e7bf 100644 --- a/.gitignore +++ b/.gitignore @@ -261,6 +261,7 @@ src/MyWebLog/wwwroot/img/daniel-j-summers src/MyWebLog/wwwroot/img/bit-badger .ionide +.vscode src/MyWebLog/appsettings.Production.json # SQLite database files diff --git a/src/MyWebLog.Data/MyWebLog.Data.fsproj b/src/MyWebLog.Data/MyWebLog.Data.fsproj index 1f1cf76..6a6ae75 100644 --- a/src/MyWebLog.Data/MyWebLog.Data.fsproj +++ b/src/MyWebLog.Data/MyWebLog.Data.fsproj @@ -5,18 +5,22 @@ - - + + - + - + + + + + diff --git a/src/MyWebLog.Domain/MyWebLog.Domain.fsproj b/src/MyWebLog.Domain/MyWebLog.Domain.fsproj index 9511caa..048dc6c 100644 --- a/src/MyWebLog.Domain/MyWebLog.Domain.fsproj +++ b/src/MyWebLog.Domain/MyWebLog.Domain.fsproj @@ -7,9 +7,9 @@ - - - + + + diff --git a/src/MyWebLog/MyWebLog.fsproj b/src/MyWebLog/MyWebLog.fsproj index 78ad373..037c8b9 100644 --- a/src/MyWebLog/MyWebLog.fsproj +++ b/src/MyWebLog/MyWebLog.fsproj @@ -23,13 +23,15 @@ - - - - - + + + + + + + diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index f114259..1437985 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -99,6 +99,8 @@ let showHelp () = open System.IO +open System.Linq +open BitBadger.AspNetCore.CanonicalDomains open Giraffe open Giraffe.EndpointRouting open Microsoft.AspNetCore.Authentication.Cookies @@ -200,6 +202,10 @@ let rec main args = do! Maintenance.loadTheme [| ""; themeFile |] app.Services let _ = app.UseForwardedHeaders () + + let domainCfg = app.Services.GetRequiredService().GetSection "CanonicalDomains" + if domainCfg.AsEnumerable().Count () > 0 then app.UseCanonicalDomains () |> ignore + let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict)) let _ = app.UseMiddleware () let _ = app.UseAuthentication () -- 2.45.1 From 5f5927c425c96f0573d108da6fcbfb4ba03d7499 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 8 Jul 2023 21:26:11 -0400 Subject: [PATCH 002/123] First cut of Docker files (#38) - Fix canonical domain config detection (#37) - Fix version insert for new SQLite dbs - Bump version to 2.1 --- src/.dockerignore | 2 ++ src/Directory.Build.props | 6 +++--- src/Dockerfile | 28 ++++++++++++++++++++++++++++ src/MyWebLog.Data/SQLiteData.fs | 2 +- src/MyWebLog/Program.fs | 5 +++-- 5 files changed, 37 insertions(+), 6 deletions(-) create mode 100644 src/.dockerignore create mode 100644 src/Dockerfile diff --git a/src/.dockerignore b/src/.dockerignore new file mode 100644 index 0000000..f181d71 --- /dev/null +++ b/src/.dockerignore @@ -0,0 +1,2 @@ +**/bin +**/obj diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 5529e72..e376d97 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,8 +2,8 @@ net6.0;net7.0 embedded - 2.0.0.0 - 2.0.0.0 - 2.0.0 + 2.1.0.0 + 2.1.0.0 + 2.1.0 diff --git a/src/Dockerfile b/src/Dockerfile new file mode 100644 index 0000000..cf71ceb --- /dev/null +++ b/src/Dockerfile @@ -0,0 +1,28 @@ +FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build +WORKDIR /mwl +COPY ./MyWebLog.sln ./ +COPY ./Directory.Build.props ./ +COPY ./MyWebLog/MyWebLog.fsproj ./MyWebLog/ +COPY ./MyWebLog.Data/MyWebLog.Data.fsproj ./MyWebLog.Data/ +COPY ./MyWebLog.Domain/MyWebLog.Domain.fsproj ./MyWebLog.Domain/ +RUN dotnet restore + +COPY . ./ +WORKDIR /mwl/MyWebLog +RUN dotnet publish -f net7.0 -c Release -r linux-x64 + +FROM alpine AS theme +RUN apk add --no-cache zip +WORKDIR /themes +COPY ./default-theme ./default-theme/ +RUN zip default-theme.zip ./default-theme/* +COPY ./admin-theme ./admin-theme/ +RUN zip admin-theme.zip ./admin-theme/* + +FROM mcr.microsoft.com/dotnet/aspnet:7.0 as final +WORKDIR /app +COPY --from=build /mwl/MyWebLog/bin/Release/net7.0/linux-x64/publish/ ./ +COPY --from=theme /themes/*.zip /app/ + +EXPOSE 80 +CMD [ "/app/MyWebLog" ] diff --git a/src/MyWebLog.Data/SQLiteData.fs b/src/MyWebLog.Data/SQLiteData.fs index 873945c..6133eb1 100644 --- a/src/MyWebLog.Data/SQLiteData.fs +++ b/src/MyWebLog.Data/SQLiteData.fs @@ -210,7 +210,7 @@ type SQLiteData (conn : SqliteConnection, log : ILogger, ser : JsonS // Database version table if needsTable "db_version" then "CREATE TABLE db_version (id TEXT PRIMARY KEY); - INSERT INTO db_version VALUES ('v2-rc1')" + INSERT INTO db_version VALUES ('v2')" } |> Seq.map (fun sql -> log.LogInformation $"Creating {(sql.Split ' ')[2]} table..." diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index 1437985..7b0c3ca 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -203,8 +203,9 @@ let rec main args = let _ = app.UseForwardedHeaders () - let domainCfg = app.Services.GetRequiredService().GetSection "CanonicalDomains" - if domainCfg.AsEnumerable().Count () > 0 then app.UseCanonicalDomains () |> ignore + (app.Services.GetRequiredService().GetSection "CanonicalDomains").Value + |> (isNull >> not) + |> function true -> app.UseCanonicalDomains () |> ignore | false -> () let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict)) let _ = app.UseMiddleware () -- 2.45.1 From 9dd6ab70e969edd2662603eca34c91e82ea8c3ba Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Tue, 18 Jul 2023 21:53:04 -0400 Subject: [PATCH 003/123] Add Sqlite Docker file - Fix import typo in RethinkDB upload restore - Switch default Docker file to alpine image --- src/Dockerfile | 10 ++++++---- src/MyWebLog.Data/RethinkDbData.fs | 6 ++++-- src/sqlite.Dockerfile | 28 ++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 src/sqlite.Dockerfile diff --git a/src/Dockerfile b/src/Dockerfile index cf71ceb..d05e417 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:7.0-alpine AS build WORKDIR /mwl COPY ./MyWebLog.sln ./ COPY ./Directory.Build.props ./ @@ -9,7 +9,7 @@ RUN dotnet restore COPY . ./ WORKDIR /mwl/MyWebLog -RUN dotnet publish -f net7.0 -c Release -r linux-x64 +RUN dotnet publish -f net7.0 -c Release -r linux-x64 --no-self-contained -p:PublishSingleFile=false FROM alpine AS theme RUN apk add --no-cache zip @@ -19,10 +19,12 @@ RUN zip default-theme.zip ./default-theme/* COPY ./admin-theme ./admin-theme/ RUN zip admin-theme.zip ./admin-theme/* -FROM mcr.microsoft.com/dotnet/aspnet:7.0 as final +FROM mcr.microsoft.com/dotnet/aspnet:7.0-alpine as final WORKDIR /app +RUN apk add --no-cache icu-libs +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false COPY --from=build /mwl/MyWebLog/bin/Release/net7.0/linux-x64/publish/ ./ COPY --from=theme /themes/*.zip /app/ EXPOSE 80 -CMD [ "/app/MyWebLog" ] +CMD [ "dotnet", "/app/MyWebLog.dll" ] diff --git a/src/MyWebLog.Data/RethinkDbData.fs b/src/MyWebLog.Data/RethinkDbData.fs index 92ace6e..9cf340e 100644 --- a/src/MyWebLog.Data/RethinkDbData.fs +++ b/src/MyWebLog.Data/RethinkDbData.fs @@ -226,7 +226,9 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger () | Some v when v = "v2-rc2" -> do! migrateV2Rc2ToV2 () - | Some v when v = "v2-rc1" -> do! migrateV2Rc1ToV2Rc2 () + | Some v when v = "v2-rc1" -> + do! migrateV2Rc1ToV2Rc2 () + do! migrateV2Rc2ToV2 () | Some _ | None -> log.LogWarning $"Unknown database version; assuming {Utils.currentDbVersion}" @@ -926,7 +928,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger List.chunkBySize 5 do do! rethink { - withTable Table.TagMap + withTable Table.Upload insert batch write; withRetryOnce; ignoreResult conn } diff --git a/src/sqlite.Dockerfile b/src/sqlite.Dockerfile new file mode 100644 index 0000000..cf71ceb --- /dev/null +++ b/src/sqlite.Dockerfile @@ -0,0 +1,28 @@ +FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build +WORKDIR /mwl +COPY ./MyWebLog.sln ./ +COPY ./Directory.Build.props ./ +COPY ./MyWebLog/MyWebLog.fsproj ./MyWebLog/ +COPY ./MyWebLog.Data/MyWebLog.Data.fsproj ./MyWebLog.Data/ +COPY ./MyWebLog.Domain/MyWebLog.Domain.fsproj ./MyWebLog.Domain/ +RUN dotnet restore + +COPY . ./ +WORKDIR /mwl/MyWebLog +RUN dotnet publish -f net7.0 -c Release -r linux-x64 + +FROM alpine AS theme +RUN apk add --no-cache zip +WORKDIR /themes +COPY ./default-theme ./default-theme/ +RUN zip default-theme.zip ./default-theme/* +COPY ./admin-theme ./admin-theme/ +RUN zip admin-theme.zip ./admin-theme/* + +FROM mcr.microsoft.com/dotnet/aspnet:7.0 as final +WORKDIR /app +COPY --from=build /mwl/MyWebLog/bin/Release/net7.0/linux-x64/publish/ ./ +COPY --from=theme /themes/*.zip /app/ + +EXPOSE 80 +CMD [ "/app/MyWebLog" ] -- 2.45.1 From ab9f2f577b41017dea79231eecba36e141f1a3d6 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Fri, 28 Jul 2023 19:41:53 -0400 Subject: [PATCH 004/123] Use PG lib data source singleton --- src/MyWebLog.Data/PostgresData.fs | 12 +++++------- src/MyWebLog/Program.fs | 16 +++++++--------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/src/MyWebLog.Data/PostgresData.fs b/src/MyWebLog.Data/PostgresData.fs index 0650379..58967d5 100644 --- a/src/MyWebLog.Data/PostgresData.fs +++ b/src/MyWebLog.Data/PostgresData.fs @@ -6,16 +6,14 @@ open Microsoft.Extensions.Logging open MyWebLog open MyWebLog.Data.Postgres open Newtonsoft.Json -open Npgsql open Npgsql.FSharp /// Data implementation for PostgreSQL -type PostgresData (source : NpgsqlDataSource, log : ILogger, ser : JsonSerializer) = +type PostgresData (log : ILogger, ser : JsonSerializer) = /// Create any needed tables let ensureTables () = backgroundTask { // Set up the PostgreSQL document store - Configuration.useDataSource source Configuration.useSerializer { new IDocumentSerializer with member _.Serialize<'T> (it : 'T) : string = Utils.serialize ser it @@ -23,9 +21,8 @@ type PostgresData (source : NpgsqlDataSource, log : ILogger, ser : } let! tables = - Sql.fromDataSource source - |> Sql.query "SELECT tablename FROM pg_tables WHERE schemaname = 'public'" - |> Sql.executeAsync (fun row -> row.string "tablename") + Custom.list "SELECT tablename FROM pg_tables WHERE schemaname = 'public'" [] + (fun row -> row.string "tablename") let needsTable table = not (List.contains table tables) // Create a document table let mutable isNew = false @@ -117,7 +114,8 @@ type PostgresData (source : NpgsqlDataSource, log : ILogger, ser : $"INSERT INTO {Table.DbVersion} VALUES ('{Utils.currentDbVersion}')" } - Sql.fromDataSource source + Configuration.dataSource () + |> Sql.fromDataSource |> Sql.executeTransactionAsync (sql |> Seq.map (fun s -> diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index 7b0c3ca..cd462bb 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -27,6 +27,7 @@ type WebLogMiddleware (next : RequestDelegate, log : ILogger) open System +open BitBadger.Npgsql.FSharp.Documents open Microsoft.Extensions.DependencyInjection open MyWebLog.Data open Newtonsoft.Json @@ -44,7 +45,7 @@ module DataImplementation = let builder = NpgsqlDataSourceBuilder (cfg.GetConnectionString "PostgreSQL") let _ = builder.UseNodaTime () // let _ = builder.UseLoggerFactory(LoggerFactory.Create(fun it -> it.AddConsole () |> ignore)) - builder.Build () + (builder.Build >> Configuration.useDataSource) () /// Get the configured data implementation let get (sp : IServiceProvider) : IData = @@ -68,11 +69,11 @@ module DataImplementation = let conn = await (rethinkCfg.CreateConnectionAsync log) RethinkDbData (conn, rethinkCfg, log) elif hasConnStr "PostgreSQL" then - let source = createNpgsqlDataSource config - use conn = source.CreateConnection () + createNpgsqlDataSource config + use conn = Configuration.dataSource().CreateConnection () let log = sp.GetRequiredService> () log.LogInformation $"Using PostgreSQL database {conn.Database}" - PostgresData (source, log, Json.configure (JsonSerializer.CreateDefault ())) + PostgresData (log, Json.configure (JsonSerializer.CreateDefault ())) else createSQLite "Data Source=./myweblog.db;Cache=Shared" @@ -99,7 +100,6 @@ let showHelp () = open System.IO -open System.Linq open BitBadger.AspNetCore.CanonicalDomains open Giraffe open Giraffe.EndpointRouting @@ -111,7 +111,7 @@ open NeoSmart.Caching.Sqlite open RethinkDB.DistributedCache [] -let rec main args = +let main args = let builder = WebApplication.CreateBuilder(args) let _ = builder.Services.Configure(fun (opts : ForwardedHeadersOptions) -> @@ -162,9 +162,7 @@ let rec main args = () | :? PostgresData as postgres -> // ADO.NET Data Sources are designed to work as singletons - let _ = - builder.Services.AddSingleton (fun sp -> - DataImplementation.createNpgsqlDataSource (sp.GetRequiredService ())) + let _ = builder.Services.AddSingleton (Configuration.dataSource ()) let _ = builder.Services.AddSingleton postgres let _ = builder.Services.AddSingleton (fun _ -> -- 2.45.1 From 42d3280f6763be4ee7739fcc917281d16143b63f Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Fri, 28 Jul 2023 20:28:03 -0400 Subject: [PATCH 005/123] Add redirect rule to data stores (#39) --- src/MyWebLog.Data/PostgresData.fs | 31 ++++++++++----- src/MyWebLog.Data/RethinkDbData.fs | 36 +++++++++++++----- src/MyWebLog.Data/SQLite/Helpers.fs | 27 ++++++------- src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs | 38 ++++++++++--------- src/MyWebLog.Data/SQLiteData.fs | 40 ++++++++++++++------ src/MyWebLog.Data/Utils.fs | 2 +- src/MyWebLog.Domain/DataTypes.fs | 28 ++++++++------ src/MyWebLog.Domain/SupportTypes.fs | 11 ++++++ 8 files changed, 139 insertions(+), 74 deletions(-) diff --git a/src/MyWebLog.Data/PostgresData.fs b/src/MyWebLog.Data/PostgresData.fs index 58967d5..66ee047 100644 --- a/src/MyWebLog.Data/PostgresData.fs +++ b/src/MyWebLog.Data/PostgresData.fs @@ -159,14 +159,28 @@ type PostgresData (log : ILogger, ser : JsonSerializer) = exit 1 } + /// Migrate from v2 to v2.1 + let migrateV2ToV2point1 () = backgroundTask { + Utils.logMigrationStep log "v2 to v2.1" "Adding empty redirect rule set to all weblogs" + do! Custom.nonQuery $"UPDATE {Table.WebLog} SET data['RedirectRules'] = '[]'::json" [] + + Utils.logMigrationStep log "v2 to v2.1" "Setting database to version 2.1" + do! setDbVersion "v2.1" + } + /// Do required data migration between versions let migrate version = backgroundTask { - match version with - | Some "v2" -> () - | Some "v2-rc2" -> do! migrateV2Rc2ToV2 () - // Future versions will be inserted here - | Some _ - | None -> + let mutable v = defaultArg version "" + + if v = "v2-rc2" then + do! migrateV2Rc2ToV2 () + v <- "v2" + + if v = "v2" then + do! migrateV2ToV2point1 () + v <- "v2.1" + + if v <> "v2.1" then log.LogWarning $"Unknown database version; assuming {Utils.currentDbVersion}" do! setDbVersion Utils.currentDbVersion } @@ -190,8 +204,5 @@ type PostgresData (log : ILogger, ser : JsonSerializer) = do! ensureTables () let! version = Custom.single "SELECT id FROM db_version" [] (fun row -> row.string "id") - match version with - | Some v when v = Utils.currentDbVersion -> () - | Some _ - | None -> do! migrate version + do! migrate version } diff --git a/src/MyWebLog.Data/RethinkDbData.fs b/src/MyWebLog.Data/RethinkDbData.fs index 9cf340e..7c318a9 100644 --- a/src/MyWebLog.Data/RethinkDbData.fs +++ b/src/MyWebLog.Data/RethinkDbData.fs @@ -220,17 +220,37 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger () - | Some v when v = "v2-rc2" -> do! migrateV2Rc2ToV2 () - | Some v when v = "v2-rc1" -> + let mutable v = defaultArg version "" + + if v = "v2-rc1" then do! migrateV2Rc1ToV2Rc2 () + v <- "v2-rc2" + + if v = "v2-rc2" then do! migrateV2Rc2ToV2 () - | Some _ - | None -> + v <- "v2" + + if v = "v2" then + do! migrateV2ToV2point1 () + v <- "v2.1" + + if v <> "v2.1" then log.LogWarning $"Unknown database version; assuming {Utils.currentDbVersion}" do! setDbVersion Utils.currentDbVersion } @@ -1185,7 +1205,5 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger () - | it -> do! migrate (it |> Option.map (fun x -> x.Id)) + do! migrate (List.tryHead version |> Option.map (fun x -> x.Id)) } diff --git a/src/MyWebLog.Data/SQLite/Helpers.fs b/src/MyWebLog.Data/SQLite/Helpers.fs index 150085f..5224674 100644 --- a/src/MyWebLog.Data/SQLite/Helpers.fs +++ b/src/MyWebLog.Data/SQLite/Helpers.fs @@ -271,19 +271,19 @@ module Map = } /// Create a web log from the current row in the given data reader - let toWebLog rdr : WebLog = - { Id = getString "id" rdr |> WebLogId - Name = getString "name" rdr - Slug = getString "slug" rdr - Subtitle = tryString "subtitle" rdr - DefaultPage = getString "default_page" rdr - PostsPerPage = getInt "posts_per_page" rdr - ThemeId = getString "theme_id" rdr |> ThemeId - UrlBase = getString "url_base" rdr - TimeZone = getString "time_zone" rdr - AutoHtmx = getBoolean "auto_htmx" rdr - Uploads = getString "uploads" rdr |> UploadDestination.parse - Rss = { + let toWebLog ser rdr : WebLog = + { Id = getString "id" rdr |> WebLogId + Name = getString "name" rdr + Slug = getString "slug" rdr + Subtitle = tryString "subtitle" rdr + DefaultPage = getString "default_page" rdr + PostsPerPage = getInt "posts_per_page" rdr + ThemeId = getString "theme_id" rdr |> ThemeId + UrlBase = getString "url_base" rdr + TimeZone = getString "time_zone" rdr + AutoHtmx = getBoolean "auto_htmx" rdr + Uploads = getString "uploads" rdr |> UploadDestination.parse + Rss = { IsFeedEnabled = getBoolean "is_feed_enabled" rdr FeedName = getString "feed_name" rdr ItemsInFeed = tryInt "items_in_feed" rdr @@ -292,6 +292,7 @@ module Map = Copyright = tryString "copyright" rdr CustomFeeds = [] } + RedirectRules = getString "redirect_rules" rdr |> Utils.deserialize ser } /// Create a web log user from the current row in the given data reader diff --git a/src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs b/src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs index aa34719..1bcfbb1 100644 --- a/src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs +++ b/src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs @@ -26,17 +26,18 @@ type SQLiteWebLogData (conn : SqliteConnection, ser : JsonSerializer) = /// 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 ("@slug", webLog.Slug) - cmd.Parameters.AddWithValue ("@subtitle", maybe webLog.Subtitle) - cmd.Parameters.AddWithValue ("@defaultPage", webLog.DefaultPage) - cmd.Parameters.AddWithValue ("@postsPerPage", webLog.PostsPerPage) - cmd.Parameters.AddWithValue ("@themeId", ThemeId.toString webLog.ThemeId) - cmd.Parameters.AddWithValue ("@urlBase", webLog.UrlBase) - cmd.Parameters.AddWithValue ("@timeZone", webLog.TimeZone) - cmd.Parameters.AddWithValue ("@autoHtmx", webLog.AutoHtmx) - cmd.Parameters.AddWithValue ("@uploads", UploadDestination.toString webLog.Uploads) + [ cmd.Parameters.AddWithValue ("@id", WebLogId.toString webLog.Id) + cmd.Parameters.AddWithValue ("@name", webLog.Name) + cmd.Parameters.AddWithValue ("@slug", webLog.Slug) + cmd.Parameters.AddWithValue ("@subtitle", maybe webLog.Subtitle) + cmd.Parameters.AddWithValue ("@defaultPage", webLog.DefaultPage) + cmd.Parameters.AddWithValue ("@postsPerPage", webLog.PostsPerPage) + cmd.Parameters.AddWithValue ("@themeId", ThemeId.toString webLog.ThemeId) + cmd.Parameters.AddWithValue ("@urlBase", webLog.UrlBase) + cmd.Parameters.AddWithValue ("@timeZone", webLog.TimeZone) + cmd.Parameters.AddWithValue ("@autoHtmx", webLog.AutoHtmx) + cmd.Parameters.AddWithValue ("@uploads", UploadDestination.toString webLog.Uploads) + cmd.Parameters.AddWithValue ("@redirectRules", Utils.serialize ser webLog.RedirectRules) ] |> ignore addWebLogRssParameters cmd webLog @@ -129,10 +130,12 @@ type SQLiteWebLogData (conn : SqliteConnection, ser : JsonSerializer) = cmd.CommandText <- "INSERT INTO web_log ( id, name, slug, subtitle, default_page, posts_per_page, theme_id, url_base, time_zone, auto_htmx, - uploads, is_feed_enabled, feed_name, items_in_feed, is_category_enabled, is_tag_enabled, copyright + uploads, is_feed_enabled, feed_name, items_in_feed, is_category_enabled, is_tag_enabled, copyright, + redirect_rules ) VALUES ( @id, @name, @slug, @subtitle, @defaultPage, @postsPerPage, @themeId, @urlBase, @timeZone, @autoHtmx, - @uploads, @isFeedEnabled, @feedName, @itemsInFeed, @isCategoryEnabled, @isTagEnabled, @copyright + @uploads, @isFeedEnabled, @feedName, @itemsInFeed, @isCategoryEnabled, @isTagEnabled, @copyright, + @redirectRules )" addWebLogParameters cmd webLog do! write cmd @@ -145,7 +148,7 @@ type SQLiteWebLogData (conn : SqliteConnection, ser : JsonSerializer) = cmd.CommandText <- "SELECT * FROM web_log" use! rdr = cmd.ExecuteReaderAsync () let! webLogs = - toList Map.toWebLog rdr + toList (Map.toWebLog ser) rdr |> List.map (fun webLog -> backgroundTask { return! appendCustomFeeds webLog }) |> Task.WhenAll return List.ofArray webLogs @@ -184,7 +187,7 @@ type SQLiteWebLogData (conn : SqliteConnection, ser : JsonSerializer) = cmd.Parameters.AddWithValue ("@urlBase", url) |> ignore use! rdr = cmd.ExecuteReaderAsync () if rdr.Read () then - let! webLog = appendCustomFeeds (Map.toWebLog rdr) + let! webLog = appendCustomFeeds (Map.toWebLog ser rdr) return Some webLog else return None @@ -197,7 +200,7 @@ type SQLiteWebLogData (conn : SqliteConnection, ser : JsonSerializer) = addWebLogId cmd webLogId use! rdr = cmd.ExecuteReaderAsync () if rdr.Read () then - let! webLog = appendCustomFeeds (Map.toWebLog rdr) + let! webLog = appendCustomFeeds (Map.toWebLog ser rdr) return Some webLog else return None @@ -223,7 +226,8 @@ type SQLiteWebLogData (conn : SqliteConnection, ser : JsonSerializer) = items_in_feed = @itemsInFeed, is_category_enabled = @isCategoryEnabled, is_tag_enabled = @isTagEnabled, - copyright = @copyright + copyright = @copyright, + redirect_rules = @redirectRules WHERE id = @id" addWebLogParameters cmd webLog do! write cmd diff --git a/src/MyWebLog.Data/SQLiteData.fs b/src/MyWebLog.Data/SQLiteData.fs index 6133eb1..89caa0c 100644 --- a/src/MyWebLog.Data/SQLiteData.fs +++ b/src/MyWebLog.Data/SQLiteData.fs @@ -65,7 +65,8 @@ type SQLiteData (conn : SqliteConnection, log : ILogger, ser : JsonS items_in_feed INTEGER, is_category_enabled INTEGER NOT NULL DEFAULT 0, is_tag_enabled INTEGER NOT NULL DEFAULT 0, - copyright TEXT); + copyright TEXT, + redirect_rules TEXT NOT NULL DEFAULT '[]'); CREATE INDEX web_log_theme_idx ON web_log (theme_id)" if needsTable "web_log_feed" then "CREATE TABLE web_log_feed ( @@ -535,15 +536,34 @@ type SQLiteData (conn : SqliteConnection, log : ILogger, ser : JsonS do! setDbVersion "v2" } + /// Migrate from v2 to v2.1 + let migrateV2ToV2point1 () = backgroundTask { + Utils.logMigrationStep log "v2 to v2.1" "Adding redirect rules to web_log table" + use cmd = conn.CreateCommand () + cmd.CommandText <- "ALTER TABLE web_log ADD COLUMN redirect_rules TEXT NOT NULL DEFAULT '[]'" + do! write cmd + + Utils.logMigrationStep log "v2 to v2.1" "Setting database version to v2.1" + do! setDbVersion "v2.1" + } + /// Migrate data among versions (up only) let migrate version = backgroundTask { + let mutable v = defaultArg version "" + + if v = "v2-rc1" then + do! migrateV2Rc1ToV2Rc2 () + v <- "v2-rc2" - match version with - | Some v when v = "v2" -> () - | Some v when v = "v2-rc2" -> do! migrateV2Rc2ToV2 () - | Some v when v = "v2-rc1" -> do! migrateV2Rc1ToV2Rc2 () - | Some _ - | None -> + if v = "v2-rc2" then + do! migrateV2Rc2ToV2 () + v <- "v2" + + if v = "v2" then + do! migrateV2ToV2point1 () + v <- "v2.1" + + if v <> "v2.1" then log.LogWarning $"Unknown database version; assuming {Utils.currentDbVersion}" do! setDbVersion Utils.currentDbVersion } @@ -580,9 +600,5 @@ type SQLiteData (conn : SqliteConnection, log : ILogger, ser : JsonS use cmd = conn.CreateCommand () cmd.CommandText <- "SELECT id FROM db_version" use! rdr = cmd.ExecuteReaderAsync () - let version = if rdr.Read () then Some (Map.getString "id" rdr) else None - match version with - | Some v when v = "v2-rc2" -> () - | Some _ - | None -> do! migrate version + do! migrate (if rdr.Read () then Some (Map.getString "id" rdr) else None) } diff --git a/src/MyWebLog.Data/Utils.fs b/src/MyWebLog.Data/Utils.fs index 9f08592..c241a65 100644 --- a/src/MyWebLog.Data/Utils.fs +++ b/src/MyWebLog.Data/Utils.fs @@ -6,7 +6,7 @@ open MyWebLog open MyWebLog.ViewModels /// The current database version -let currentDbVersion = "v2" +let currentDbVersion = "v2.1" /// Create a category hierarchy from the given list of categories let rec orderByHierarchy (cats : Category list) parentId slugBase parentNames = seq { diff --git a/src/MyWebLog.Domain/DataTypes.fs b/src/MyWebLog.Domain/DataTypes.fs index 87b9a1c..c547389 100644 --- a/src/MyWebLog.Domain/DataTypes.fs +++ b/src/MyWebLog.Domain/DataTypes.fs @@ -375,6 +375,9 @@ type WebLog = /// Where uploads are placed Uploads : UploadDestination + + /// Redirect rules for this weblog + RedirectRules : RedirectRule list } /// Functions to support web logs @@ -382,18 +385,19 @@ module WebLog = /// An empty web log let empty = - { Id = WebLogId.empty - Name = "" - Slug = "" - Subtitle = None - DefaultPage = "" - PostsPerPage = 10 - ThemeId = ThemeId "default" - UrlBase = "" - TimeZone = "" - Rss = RssOptions.empty - AutoHtmx = false - Uploads = Database + { Id = WebLogId.empty + Name = "" + Slug = "" + Subtitle = None + DefaultPage = "" + PostsPerPage = 10 + ThemeId = ThemeId "default" + UrlBase = "" + TimeZone = "" + Rss = RssOptions.empty + AutoHtmx = false + Uploads = Database + RedirectRules = [] } /// Get the host (including scheme) and extra path from the URL base diff --git a/src/MyWebLog.Domain/SupportTypes.fs b/src/MyWebLog.Domain/SupportTypes.fs index 4753583..4a525c7 100644 --- a/src/MyWebLog.Domain/SupportTypes.fs +++ b/src/MyWebLog.Domain/SupportTypes.fs @@ -422,6 +422,17 @@ module PostId = let create () = PostId (newId ()) +/// A redirection for a previously valid URL +type RedirectRule = + { /// The From string or pattern + From : string + /// The To string or pattern + To : string + /// Whether to use regular expressions on this rule + IsRegex : bool + } + + /// An identifier for a custom feed type CustomFeedId = CustomFeedId of string -- 2.45.1 From 3ef4499a9031397e399c9708363e43c5b414c718 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Fri, 28 Jul 2023 22:07:13 -0400 Subject: [PATCH 006/123] Add redirect index page (#39) - Bump version to 2.1 --- src/MyWebLog.Data/RethinkDbData.fs | 2 +- src/MyWebLog/Handlers/Admin.fs | 25 +++++++++--- src/MyWebLog/Handlers/Routes.fs | 3 ++ src/MyWebLog/appsettings.json | 1 + src/admin-theme/redirect-list.liquid | 58 ++++++++++++++++++++++++++++ src/admin-theme/settings.liquid | 3 +- src/admin-theme/version.txt | 2 +- 7 files changed, 85 insertions(+), 9 deletions(-) create mode 100644 src/admin-theme/redirect-list.liquid diff --git a/src/MyWebLog.Data/RethinkDbData.fs b/src/MyWebLog.Data/RethinkDbData.fs index 7c318a9..ccf4ef2 100644 --- a/src/MyWebLog.Data/RethinkDbData.fs +++ b/src/MyWebLog.Data/RethinkDbData.fs @@ -226,7 +226,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger obj ] write; withRetryOnce; ignoreResult conn } diff --git a/src/MyWebLog/Handlers/Admin.fs b/src/MyWebLog/Handlers/Admin.fs index 30ebac4..1808676 100644 --- a/src/MyWebLog/Handlers/Admin.fs +++ b/src/MyWebLog/Handlers/Admin.fs @@ -7,7 +7,7 @@ open MyWebLog open MyWebLog.ViewModels open NodaTime -/// ~~ DASHBOARDS ~~ +/// ~~~ DASHBOARDS ~~~ module Dashboard = // GET /admin/dashboard @@ -75,7 +75,7 @@ module Dashboard = let toAdminDashboard : HttpHandler = redirectToGet "admin/administration" -/// ~~ CACHES ~~ +/// ~~~ CACHES ~~~ module Cache = // POST /admin/cache/web-log/{id}/refresh @@ -126,7 +126,7 @@ module Cache = } -/// ~~ CATEGORIES ~~ +/// ~~~ CATEGORIES ~~~ module Category = open MyWebLog.Data @@ -214,7 +214,20 @@ module Category = } -/// ~~ TAG MAPPINGS ~~ +/// ~~~ REDIRECT RULES ~~~ +module RedirectRules = + + // GET /admin/redirect-rules + let all : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { + return! + hashForPage "Redirect Rules" + |> withAntiCsrf ctx + |> addToHash "redirections" ctx.WebLog.RedirectRules + |> adminView "redirect-list" next ctx + } + + +/// ~~~ TAG MAPPINGS ~~~ module TagMapping = open Microsoft.AspNetCore.Http @@ -278,7 +291,7 @@ module TagMapping = } -/// ~~ THEMES ~~ +/// ~~~ THEMES ~~~ module Theme = open System @@ -440,7 +453,7 @@ module Theme = } -/// ~~ WEB LOG SETTINGS ~~ +/// ~~~ WEB LOG SETTINGS ~~~ module WebLog = open System.Collections.Generic diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index e664a9d..0c82308 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -130,6 +130,9 @@ let router : HttpHandler = choose [ routef "/%s/revision/%s/preview" Post.previewRevision routef "/%s/revisions" Post.editRevisions ]) + subRoute "/redirect-rules" (choose [ + route "" >=> Admin.RedirectRules.all + ]) subRoute "/settings" (choose [ route "" >=> Admin.WebLog.settings routef "/rss/%s/edit" Feed.editCustomFeed diff --git a/src/MyWebLog/appsettings.json b/src/MyWebLog/appsettings.json index e89af6d..5956ea7 100644 --- a/src/MyWebLog/appsettings.json +++ b/src/MyWebLog/appsettings.json @@ -1,5 +1,6 @@ { "Generator": "myWebLog 2.0", + "Generator": "myWebLog 2.1", "Logging": { "LogLevel": { "MyWebLog.Handlers": "Information" diff --git a/src/admin-theme/redirect-list.liquid b/src/admin-theme/redirect-list.liquid new file mode 100644 index 0000000..88ff325 --- /dev/null +++ b/src/admin-theme/redirect-list.liquid @@ -0,0 +1,58 @@ +

Redirect Rules

+
+ « Back to Settings +
+
+
+ {%- assign redir_count = redirections | size -%} + {% if redir_count > 0 -%} +
+
+
From
+
To
+
RegEx?
+
+
+
+ +
+ {% for redir in redirections -%} + {%- assign map_id = mapping_ids | value: map.tag -%} +
+
+ {{ redir.from }}
+ + {%- assign redir_url = "admin/settings/redirect-rules/" | append: forloop.index0 -%} + + Edit + + {% unless forloop.first %} + + {%- assign move_up = redir_url | append: "/up" | relative_link -%} + Move Up + {% endunless %} + {% unless forloop.last %} + + {%- assign move_down = redir_url | append: "/down" | relative_link -%} + Move Down + {% endunless %} + + {%- assign del_url = redir_url | append: "/delete" | relative_link -%} + Delete + +
+
{{ redir.to }}
+
{% if redir.is_regex %}Yes{% else %}No{% endif %}
+
+ {%- endfor %} +
+ {%- else -%} +
+

This web log has no redirect rules defined

+
+ {%- endif %} +
+
+
+
diff --git a/src/admin-theme/settings.liquid b/src/admin-theme/settings.liquid index ab956f8..92c5649 100644 --- a/src/admin-theme/settings.liquid +++ b/src/admin-theme/settings.liquid @@ -2,7 +2,8 @@

Go to: UsersRSS Settings • - Tag Mappings + Tag Mappings • + Redirect Rules

Web Log Settings diff --git a/src/admin-theme/version.txt b/src/admin-theme/version.txt index 821af72..eb1fc5a 100644 --- a/src/admin-theme/version.txt +++ b/src/admin-theme/version.txt @@ -1,2 +1,2 @@ myWebLog Admin -2.0.0 \ No newline at end of file +2.1.0 \ No newline at end of file -- 2.45.1 From dc6b066e79b1411796a65e0c01b5dd876b7462f2 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 30 Jul 2023 21:00:31 -0400 Subject: [PATCH 007/123] Rule add/edit/move/delete works (#39) - Begin moving auth to route definition where practical - Fix typo on post list page --- src/MyWebLog.Data/Interfaces.fs | 3 + .../Postgres/PostgresWebLogData.fs | 20 +++- src/MyWebLog.Data/PostgresData.fs | 2 +- src/MyWebLog.Data/RethinkDbData.fs | 7 ++ src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs | 48 ++++---- src/MyWebLog.Domain/SupportTypes.fs | 12 ++ src/MyWebLog.Domain/ViewModels.fs | 37 +++++++ src/MyWebLog/DotLiquidBespoke.fs | 18 +-- src/MyWebLog/Handlers/Admin.fs | 104 +++++++++++++++--- src/MyWebLog/Handlers/Routes.fs | 31 ++++-- src/MyWebLog/Handlers/User.fs | 6 +- src/admin-theme/post-list.liquid | 2 +- src/admin-theme/redirect-edit.liquid | 48 ++++++++ src/admin-theme/redirect-list.liquid | 79 +++++++------ src/admin-theme/settings.liquid | 2 +- 15 files changed, 322 insertions(+), 97 deletions(-) create mode 100644 src/admin-theme/redirect-edit.liquid diff --git a/src/MyWebLog.Data/Interfaces.fs b/src/MyWebLog.Data/Interfaces.fs index f064cc4..0ec05d5 100644 --- a/src/MyWebLog.Data/Interfaces.fs +++ b/src/MyWebLog.Data/Interfaces.fs @@ -259,6 +259,9 @@ type IWebLogData = /// Find a web log by its ID abstract member FindById : WebLogId -> Task + /// Update redirect rules for a web log + abstract member UpdateRedirectRules : WebLog -> Task + /// Update RSS options for a web log abstract member UpdateRssOptions : WebLog -> Task diff --git a/src/MyWebLog.Data/Postgres/PostgresWebLogData.fs b/src/MyWebLog.Data/Postgres/PostgresWebLogData.fs index 713005b..ef6020c 100644 --- a/src/MyWebLog.Data/Postgres/PostgresWebLogData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresWebLogData.fs @@ -45,11 +45,13 @@ type PostgresWebLogData (log : ILogger) = log.LogTrace "WebLog.findById" Find.byId Table.WebLog (WebLogId.toString webLogId) - /// Update settings for a web log - let updateSettings (webLog : WebLog) = - log.LogTrace "WebLog.updateSettings" - Update.full Table.WebLog (WebLogId.toString webLog.Id) webLog - + let updateRedirectRules (webLog : WebLog) = backgroundTask { + log.LogTrace "WebLog.updateRedirectRules" + match! findById webLog.Id with + | Some _ -> + do! Update.partialById Table.WebLog (WebLogId.toString webLog.Id) {| RedirectRules = webLog.RedirectRules |} + | None -> () + } /// Update RSS options for a web log let updateRssOptions (webLog : WebLog) = backgroundTask { log.LogTrace "WebLog.updateRssOptions" @@ -58,11 +60,17 @@ type PostgresWebLogData (log : ILogger) = | None -> () } + /// Update settings for a web log + let updateSettings (webLog : WebLog) = + log.LogTrace "WebLog.updateSettings" + Update.full Table.WebLog (WebLogId.toString webLog.Id) webLog + interface IWebLogData with member _.Add webLog = add webLog member _.All () = all () member _.Delete webLogId = delete webLogId member _.FindByHost url = findByHost url member _.FindById webLogId = findById webLogId - member _.UpdateSettings webLog = updateSettings webLog + member _.UpdateRedirectRules webLog = updateRedirectRules webLog member _.UpdateRssOptions webLog = updateRssOptions webLog + member _.UpdateSettings webLog = updateSettings webLog diff --git a/src/MyWebLog.Data/PostgresData.fs b/src/MyWebLog.Data/PostgresData.fs index 66ee047..0b788b0 100644 --- a/src/MyWebLog.Data/PostgresData.fs +++ b/src/MyWebLog.Data/PostgresData.fs @@ -162,7 +162,7 @@ type PostgresData (log : ILogger, ser : JsonSerializer) = /// Migrate from v2 to v2.1 let migrateV2ToV2point1 () = backgroundTask { Utils.logMigrationStep log "v2 to v2.1" "Adding empty redirect rule set to all weblogs" - do! Custom.nonQuery $"UPDATE {Table.WebLog} SET data['RedirectRules'] = '[]'::json" [] + do! Custom.nonQuery $"""UPDATE {Table.WebLog} SET data = data + '{{ "RedirectRules": [] }}'::json""" [] Utils.logMigrationStep log "v2 to v2.1" "Setting database to version 2.1" do! setDbVersion "v2.1" diff --git a/src/MyWebLog.Data/RethinkDbData.fs b/src/MyWebLog.Data/RethinkDbData.fs index ccf4ef2..73a625f 100644 --- a/src/MyWebLog.Data/RethinkDbData.fs +++ b/src/MyWebLog.Data/RethinkDbData.fs @@ -1031,6 +1031,13 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger obj ] + write; withRetryDefault; ignoreResult conn + } + member _.UpdateRssOptions webLog = rethink { withTable Table.WebLog get webLog.Id diff --git a/src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs b/src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs index 1bcfbb1..f4b64e7 100644 --- a/src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs +++ b/src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs @@ -206,6 +206,33 @@ type SQLiteWebLogData (conn : SqliteConnection, ser : JsonSerializer) = return None } + /// Update redirect rules for a web log + let updateRedirectRules webLog = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- "UPDATE web_log SET redirect_rules = @redirectRules WHERE id = @id" + cmd.Parameters.AddWithValue ("@redirectRules", Utils.serialize ser webLog.RedirectRules) |> ignore + cmd.Parameters.AddWithValue ("@id", WebLogId.toString webLog.Id) |> ignore + do! write cmd + } + + /// Update RSS options for a web log + let updateRssOptions webLog = backgroundTask { + use cmd = conn.CreateCommand () + cmd.CommandText <- + "UPDATE web_log + SET is_feed_enabled = @isFeedEnabled, + feed_name = @feedName, + items_in_feed = @itemsInFeed, + is_category_enabled = @isCategoryEnabled, + is_tag_enabled = @isTagEnabled, + copyright = @copyright + WHERE id = @id" + addWebLogRssParameters cmd webLog + cmd.Parameters.AddWithValue ("@id", WebLogId.toString webLog.Id) |> ignore + do! write cmd + do! updateCustomFeeds webLog + } + /// Update settings for a web log let updateSettings webLog = backgroundTask { use cmd = conn.CreateCommand () @@ -233,29 +260,12 @@ type SQLiteWebLogData (conn : SqliteConnection, ser : JsonSerializer) = do! write cmd } - /// Update RSS options for a web log - let updateRssOptions webLog = backgroundTask { - use cmd = conn.CreateCommand () - cmd.CommandText <- - "UPDATE web_log - SET is_feed_enabled = @isFeedEnabled, - feed_name = @feedName, - items_in_feed = @itemsInFeed, - is_category_enabled = @isCategoryEnabled, - is_tag_enabled = @isTagEnabled, - copyright = @copyright - WHERE id = @id" - addWebLogRssParameters cmd webLog - cmd.Parameters.AddWithValue ("@id", WebLogId.toString webLog.Id) |> ignore - do! write cmd - do! updateCustomFeeds webLog - } - interface IWebLogData with member _.Add webLog = add webLog member _.All () = all () member _.Delete webLogId = delete webLogId member _.FindByHost url = findByHost url member _.FindById webLogId = findById webLogId - member _.UpdateSettings webLog = updateSettings webLog + member _.UpdateRedirectRules webLog = updateRedirectRules webLog member _.UpdateRssOptions webLog = updateRssOptions webLog + member _.UpdateSettings webLog = updateSettings webLog diff --git a/src/MyWebLog.Domain/SupportTypes.fs b/src/MyWebLog.Domain/SupportTypes.fs index 4a525c7..1ece831 100644 --- a/src/MyWebLog.Domain/SupportTypes.fs +++ b/src/MyWebLog.Domain/SupportTypes.fs @@ -426,12 +426,24 @@ module PostId = type RedirectRule = { /// The From string or pattern From : string + /// The To string or pattern To : string + /// Whether to use regular expressions on this rule IsRegex : bool } +/// Functions to support redirect rules +module RedirectRule = + + /// An empty redirect rule + let empty = + { From = "" + To = "" + IsRegex = false + } + /// An identifier for a custom feed type CustomFeedId = CustomFeedId of string diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index f7d204f..61ece89 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -807,6 +807,43 @@ type EditPostModel = } +/// View model to add/edit a redirect rule +[] +type EditRedirectRuleModel = + { /// The ID (index) of the rule being edited + RuleId : int + + /// The "from" side of the rule + From : string + + /// The "to" side of the rule + To : string + + /// Whether this rule uses a regular expression + IsRegex : bool + + /// Whether a new rule should be inserted at the top or appended to the end (ignored for edits) + InsertAtTop : bool + } + + /// Create a model from an existing rule + static member fromRule idx (rule : RedirectRule) = + { RuleId = idx + From = rule.From + To = rule.To + IsRegex = rule.IsRegex + InsertAtTop = false + } + + /// Update a rule with the values from this model + member this.UpdateRule (rule : RedirectRule) = + { rule with + From = this.From + To = this.To + IsRegex = this.IsRegex + } + + /// View model to edit RSS settings [] type EditRssModel = diff --git a/src/MyWebLog/DotLiquidBespoke.fs b/src/MyWebLog/DotLiquidBespoke.fs index 4cbd799..83e9a3f 100644 --- a/src/MyWebLog/DotLiquidBespoke.fs +++ b/src/MyWebLog/DotLiquidBespoke.fs @@ -224,15 +224,17 @@ let register () = Template.RegisterTag "user_links" [ // Domain types - typeof; typeof; typeof; typeof; typeof - typeof; typeof; typeof; typeof + typeof; typeof; typeof; typeof; 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; typeof; typeof; typeof - typeof; typeof; typeof + typeof; typeof; typeof + typeof; typeof; typeof + typeof; 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; typeof diff --git a/src/MyWebLog/Handlers/Admin.fs b/src/MyWebLog/Handlers/Admin.fs index 1808676..4025843 100644 --- a/src/MyWebLog/Handlers/Admin.fs +++ b/src/MyWebLog/Handlers/Admin.fs @@ -132,7 +132,7 @@ module Category = open MyWebLog.Data // GET /admin/categories - let all : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { + let all : HttpHandler = fun next ctx -> task { match! TemplateCache.get adminTheme "category-list-body" ctx.Data with | Ok catListTemplate -> let! hash = @@ -146,14 +146,14 @@ module Category = } // GET /admin/categories/bare - let bare : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> + let bare : HttpHandler = fun next ctx -> hashForPage "Categories" |> withAntiCsrf ctx |> adminBareView "category-list-body" next ctx // GET /admin/category/{id}/edit - let edit catId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { + let edit 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" }) @@ -173,7 +173,7 @@ module Category = } // POST /admin/category/save - let save : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { + let save : HttpHandler = fun next ctx -> task { let data = ctx.Data let! model = ctx.BindFormAsync () let category = @@ -196,7 +196,7 @@ module Category = } // POST /admin/category/{id}/delete - let delete catId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { + let delete catId : HttpHandler = fun next ctx -> task { let! result = ctx.Data.Category.Delete (CategoryId catId) ctx.WebLog.Id match result with | CategoryDeleted @@ -217,8 +217,10 @@ module Category = /// ~~~ REDIRECT RULES ~~~ module RedirectRules = - // GET /admin/redirect-rules - let all : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { + open Microsoft.AspNetCore.Http + + // GET /admin/settings/redirect-rules + let all : HttpHandler = fun next ctx -> task { return! hashForPage "Redirect Rules" |> withAntiCsrf ctx @@ -226,6 +228,82 @@ module RedirectRules = |> adminView "redirect-list" next ctx } + // GET /admin/settings/redirect-rules/[index] + let edit idx : HttpHandler = fun next ctx -> task { + if idx = -1 then + return! + hashForPage "Add Redirect Rule" + |> addToHash "model" (EditRedirectRuleModel.fromRule -1 RedirectRule.empty) + |> withAntiCsrf ctx + |> adminBareView "redirect-edit" next ctx + else + let rules = ctx.WebLog.RedirectRules + if rules.Length < idx || idx < 0 then + return! Error.notFound next ctx + else + return! + hashForPage "Edit Redirect Rule" + |> addToHash "model" (EditRedirectRuleModel.fromRule idx (List.item idx rules)) + |> withAntiCsrf ctx + |> adminBareView "redirect-edit" next ctx + } + + /// Update the web log's redirect rules in the database, the request web log, and the web log cache + let private updateRedirectRules (ctx : HttpContext) webLog = backgroundTask { + do! ctx.Data.WebLog.UpdateRedirectRules webLog + ctx.Items["webLog"] <- webLog + WebLogCache.set webLog + } + + // POST /admin/settings/redirect-rules/[index] + let save idx : HttpHandler = fun next ctx -> task { + let! model = ctx.BindFormAsync () + let isNew = idx = -1 + let rules = ctx.WebLog.RedirectRules + let rule = model.UpdateRule (if isNew then RedirectRule.empty else List.item idx rules) + let newRules = + match isNew with + | true when model.InsertAtTop -> List.insertAt 0 rule rules + | true -> List.insertAt (rules.Length) rule rules + | false -> rules |> List.removeAt idx |> List.insertAt idx rule + do! updateRedirectRules ctx { ctx.WebLog with RedirectRules = newRules } + do! addMessage ctx { UserMessage.success with Message = "Redirect rule saved successfully" } + return! all next ctx + } + + // POST /admin/settings/redirect-rules/[index]/up + let moveUp idx : HttpHandler = fun next ctx -> task { + if idx < 1 || idx >= ctx.WebLog.RedirectRules.Length then + return! Error.notFound next ctx + else + let toMove = List.item idx ctx.WebLog.RedirectRules + let newRules = ctx.WebLog.RedirectRules |> List.removeAt idx |> List.insertAt (idx - 1) toMove + do! updateRedirectRules ctx { ctx.WebLog with RedirectRules = newRules } + return! all next ctx + } + + // POST /admin/settings/redirect-rules/[index]/down + let moveDown idx : HttpHandler = fun next ctx -> task { + if idx < 0 || idx >= ctx.WebLog.RedirectRules.Length - 1 then + return! Error.notFound next ctx + else + let toMove = List.item idx ctx.WebLog.RedirectRules + let newRules = ctx.WebLog.RedirectRules |> List.removeAt idx |> List.insertAt (idx + 1) toMove + do! updateRedirectRules ctx { ctx.WebLog with RedirectRules = newRules } + return! all next ctx + } + + // POST /admin/settings/redirect-rules/[index]/delete + let delete idx : HttpHandler = fun next ctx -> task { + if idx < 0 || idx >= ctx.WebLog.RedirectRules.Length then + return! Error.notFound next ctx + else + let rules = ctx.WebLog.RedirectRules |> List.removeAt idx + do! updateRedirectRules ctx { ctx.WebLog with RedirectRules = rules } + do! addMessage ctx { UserMessage.success with Message = "Redirect rule deleted successfully" } + return! all next ctx + } + /// ~~~ TAG MAPPINGS ~~~ module TagMapping = @@ -243,7 +321,7 @@ module TagMapping = } // GET /admin/settings/tag-mappings - let all : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { + let all : HttpHandler = fun next ctx -> task { let! hash = hashForPage "" |> withAntiCsrf ctx @@ -252,7 +330,7 @@ module TagMapping = } // GET /admin/settings/tag-mapping/{id}/edit - let edit tagMapId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { + let edit tagMapId : HttpHandler = fun next ctx -> task { let isNew = tagMapId = "new" let tagMap = if isNew then someTask { TagMap.empty with Id = TagMapId "new" } @@ -268,7 +346,7 @@ module TagMapping = } // POST /admin/settings/tag-mapping/save - let save : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { + let save : HttpHandler = fun next ctx -> task { let data = ctx.Data let! model = ctx.BindFormAsync () let tagMap = @@ -283,7 +361,7 @@ module TagMapping = } // POST /admin/settings/tag-mapping/{id}/delete - let delete tagMapId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { + let delete 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" } @@ -460,7 +538,7 @@ module WebLog = open System.IO // GET /admin/settings - let settings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { + let settings : HttpHandler = fun next ctx -> task { let data = ctx.Data match! TemplateCache.get adminTheme "user-list-body" data with | Ok userTemplate -> @@ -508,7 +586,7 @@ module WebLog = } // POST /admin/settings - let saveSettings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { + let saveSettings : HttpHandler = fun next ctx -> task { let data = ctx.Data let! model = ctx.BindFormAsync () match! data.WebLog.FindById ctx.WebLog.Id with diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index 0c82308..3b8f74d 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -107,7 +107,7 @@ let router : HttpHandler = choose [ subRoute "/admin" (requireUser >=> choose [ GET_HEAD >=> choose [ route "/administration" >=> Admin.Dashboard.admin - subRoute "/categor" (choose [ + subRoute "/categor" (requireAccess WebLogAdmin >=> choose [ route "ies" >=> Admin.Category.all route "ies/bare" >=> Admin.Category.bare routef "y/%s/edit" Admin.Category.edit @@ -130,20 +130,21 @@ let router : HttpHandler = choose [ routef "/%s/revision/%s/preview" Post.previewRevision routef "/%s/revisions" Post.editRevisions ]) - subRoute "/redirect-rules" (choose [ - route "" >=> Admin.RedirectRules.all - ]) - subRoute "/settings" (choose [ - route "" >=> Admin.WebLog.settings - routef "/rss/%s/edit" Feed.editCustomFeed - subRoute "/user" (choose [ - route "s" >=> User.all - routef "/%s/edit" User.edit + subRoute "/settings" (requireAccess WebLogAdmin >=> choose [ + route "" >=> Admin.WebLog.settings + routef "/rss/%s/edit" Feed.editCustomFeed + subRoute "/redirect-rules" (choose [ + route "" >=> Admin.RedirectRules.all + routef "/%i" Admin.RedirectRules.edit ]) subRoute "/tag-mapping" (choose [ route "s" >=> Admin.TagMapping.all routef "/%s/edit" Admin.TagMapping.edit ]) + subRoute "/user" (choose [ + route "s" >=> User.all + routef "/%s/edit" User.edit + ]) ]) subRoute "/theme" (choose [ route "/list" >=> Admin.Theme.all @@ -159,7 +160,7 @@ let router : HttpHandler = choose [ routef "/theme/%s/refresh" Admin.Cache.refreshTheme routef "/web-log/%s/refresh" Admin.Cache.refreshWebLog ]) - subRoute "/category" (choose [ + subRoute "/category" (requireAccess WebLogAdmin >=> choose [ route "/save" >=> Admin.Category.save routef "/%s/delete" Admin.Category.delete ]) @@ -180,13 +181,19 @@ let router : HttpHandler = choose [ routef "/%s/revision/%s/restore" Post.restoreRevision routef "/%s/revisions/purge" Post.purgeRevisions ]) - subRoute "/settings" (choose [ + subRoute "/settings" (requireAccess WebLogAdmin >=> choose [ route "" >=> Admin.WebLog.saveSettings subRoute "/rss" (choose [ route "" >=> Feed.saveSettings route "/save" >=> Feed.saveCustomFeed routef "/%s/delete" Feed.deleteCustomFeed ]) + subRoute "/redirect-rules" (choose [ + routef "/%i" Admin.RedirectRules.save + routef "/%i/up" Admin.RedirectRules.moveUp + routef "/%i/down" Admin.RedirectRules.moveDown + routef "/%i/delete" Admin.RedirectRules.delete + ]) subRoute "/tag-mapping" (choose [ route "/save" >=> Admin.TagMapping.save routef "/%s/delete" Admin.TagMapping.delete diff --git a/src/MyWebLog/Handlers/User.fs b/src/MyWebLog/Handlers/User.fs index 6a67a61..43d9ccc 100644 --- a/src/MyWebLog/Handlers/User.fs +++ b/src/MyWebLog/Handlers/User.fs @@ -95,7 +95,7 @@ open Giraffe.Htmx let private goAway : HttpHandler = RequestErrors.BAD_REQUEST "really?" // GET /admin/settings/users -let all : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { +let all : HttpHandler = fun next ctx -> task { let! users = ctx.Data.WebLogUser.FindByWebLog ctx.WebLog.Id return! hashForPage "User Administration" @@ -119,7 +119,7 @@ let private showEdit (model : EditUserModel) : HttpHandler = fun next ctx -> |> adminBareView "user-edit" next ctx // GET /admin/settings/user/{id}/edit -let edit usrId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { +let edit usrId : HttpHandler = fun next ctx -> task { let isNew = usrId = "new" let userId = WebLogUserId usrId let tryUser = @@ -131,7 +131,7 @@ let edit usrId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> tas } // POST /admin/settings/user/{id}/delete -let delete userId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { +let delete userId : HttpHandler = fun next ctx -> task { let data = ctx.Data match! data.WebLogUser.FindById (WebLogUserId userId) ctx.WebLog.Id with | Some user -> diff --git a/src/admin-theme/post-list.liquid b/src/admin-theme/post-list.liquid index b597187..ffb9994 100644 --- a/src/admin-theme/post-list.liquid +++ b/src/admin-theme/post-list.liquid @@ -54,7 +54,7 @@ {%- assign post_del_link = "admin/post/" | append: post.id | append: "/delete" | relative_link -%} + hx-confirm="Are you sure you want to delete the post “{{ post.title | strip_html | escape }}”? This action cannot be undone."> Delete {% endif %} diff --git a/src/admin-theme/redirect-edit.liquid b/src/admin-theme/redirect-edit.liquid new file mode 100644 index 0000000..d35172d --- /dev/null +++ b/src/admin-theme/redirect-edit.liquid @@ -0,0 +1,48 @@ +

{% if model.rule_id < 0 %}Add{% else %}Edit{% endif %} Redirect Rule

+{%- assign post_url = "admin/settings/redirect-rules/" | append: model.rule_id | relative_link -%} +
+ + +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ {% if model.rule_id < 0 %} +
+
+ +
+ + + + +
+
+
+ {% endif %} +
+
+ + Cancel +
+
+
diff --git a/src/admin-theme/redirect-list.liquid b/src/admin-theme/redirect-list.liquid index 88ff325..232ead0 100644 --- a/src/admin-theme/redirect-list.liquid +++ b/src/admin-theme/redirect-list.liquid @@ -1,7 +1,17 @@ -

Redirect Rules

+

{{ page_title }}

- « Back to Settings +

+ « Back to Settings +

+
{%- assign redir_count = redirections | size -%} @@ -13,42 +23,45 @@
RegEx?
-
+
+ -
- {% for redir in redirections -%} - {%- assign map_id = mapping_ids | value: map.tag -%} -
-
- {{ redir.from }}
- - {%- assign redir_url = "admin/settings/redirect-rules/" | append: forloop.index0 -%} - - Edit - - {% unless forloop.first %} - - {%- assign move_up = redir_url | append: "/up" | relative_link -%} - Move Up - {% endunless %} - {% unless forloop.last %} - - {%- assign move_down = redir_url | append: "/down" | relative_link -%} - Move Down - {% endunless %} + {% for redir in redirections -%} + {%- assign redir_id = "redir_" | append: forloop.index0 -%} +
+
+ {{ redir.from }}
+ + {%- assign redir_url = "admin/settings/redirect-rules/" | append: forloop.index0 -%} + + Edit + + {% unless forloop.first %} - {%- assign del_url = redir_url | append: "/delete" | relative_link -%} - Delete - -
-
{{ redir.to }}
-
{% if redir.is_regex %}Yes{% else %}No{% endif %}
+ {%- assign move_up = redir_url | append: "/up" | relative_link -%} + Move Up + {% endunless %} + {% unless forloop.last %} + + {%- assign move_down = redir_url | append: "/down" | relative_link -%} + Move Down + {% endunless %} + + {%- assign del_url = redir_url | append: "/delete" | relative_link -%} + + Delete + +
- {%- endfor %} +
{{ redir.to }}
+
{% if redir.is_regex %}Yes{% else %}No{% endif %}
+
+ {%- endfor %} {%- else -%} -
+

This web log has no redirect rules defined

{%- endif %} diff --git a/src/admin-theme/settings.liquid b/src/admin-theme/settings.liquid index 92c5649..ed75e5c 100644 --- a/src/admin-theme/settings.liquid +++ b/src/admin-theme/settings.liquid @@ -3,7 +3,7 @@

Go to: UsersRSS SettingsTag Mappings • - Redirect Rules + Redirect Rules

Web Log Settings -- 2.45.1 From 693a1df34fff13e782dc4081bf1297ebe01ad487 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 30 Jul 2023 22:26:30 -0400 Subject: [PATCH 008/123] Redirect plain-text rules (#39) regex still untested --- src/MyWebLog/Caches.fs | 28 +++++++++++++++++++++++++++- src/MyWebLog/Program.fs | 25 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/MyWebLog/Caches.fs b/src/MyWebLog/Caches.fs index 2c4e74b..2b66b59 100644 --- a/src/MyWebLog/Caches.fs +++ b/src/MyWebLog/Caches.fs @@ -65,9 +65,21 @@ open System.Collections.Concurrent /// settings update page module WebLogCache = + open System.Text.RegularExpressions + + /// A redirect rule that caches compiled regular expression rules + type CachedRedirectRule = + /// A straight text match rule + | Text of string * string + /// A regular expression match rule + | RegEx of Regex * string + /// The cache of web log details let mutable private _cache : WebLog list = [] + /// Redirect rules with compiled regular expressions + let mutable private _redirectCache = ConcurrentDictionary () + /// Try to get the web log for the current request (longest matching URL base wins) let tryGet (path : string) = _cache @@ -78,6 +90,16 @@ module WebLogCache = /// Cache the web log for a particular host let set webLog = _cache <- webLog :: (_cache |> List.filter (fun wl -> wl.Id <> webLog.Id)) + _redirectCache[webLog.Id] <- + webLog.RedirectRules + |> List.map (fun it -> + let relUrl = Permalink >> WebLog.relativeUrl webLog + let urlTo = if it.To.Contains "://" then it.To else relUrl it.To + if it.IsRegex then + let pattern = if it.From.StartsWith "^" then $"^{relUrl (it.From.Substring 1)}" else it.From + RegEx (new Regex (pattern, RegexOptions.Compiled ||| RegexOptions.IgnoreCase), urlTo) + else + Text (relUrl it.From, urlTo)) /// Get all cached web logs let all () = @@ -86,9 +108,13 @@ module WebLogCache = /// Fill the web log cache from the database let fill (data : IData) = backgroundTask { let! webLogs = data.WebLog.All () - _cache <- webLogs + webLogs |> List.iter set } + /// Get the cached redirect rules for the given web log + let redirectRules webLogId = + _redirectCache[webLogId] + /// Is the given theme in use by any web logs? let isThemeInUse themeId = _cache |> List.exists (fun wl -> wl.ThemeId = themeId) diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index cd462bb..2a94084 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -26,6 +26,30 @@ type WebLogMiddleware (next : RequestDelegate, log : ILogger) } +/// Middleware to check redirects for the current web log +type RedirectRuleMiddleware (next : RequestDelegate, log : ILogger) = + + /// Shorthand for case-insensitive string equality + let ciEquals str1 str2 = + System.String.Equals (str1, str2, System.StringComparison.InvariantCultureIgnoreCase) + + member _.InvokeAsync (ctx : HttpContext) = task { + let path = ctx.Request.Path.Value.ToLower () + let matched = + WebLogCache.redirectRules ctx.WebLog.Id + |> List.tryPick (fun rule -> + match rule with + | WebLogCache.CachedRedirectRule.Text (urlFrom, urlTo) -> + log.LogInformation $"Checking {path} against from={urlFrom} and to={urlTo}" + if ciEquals path urlFrom then Some urlTo else None + | WebLogCache.CachedRedirectRule.RegEx (regExFrom, patternTo) -> + if regExFrom.IsMatch path then Some (regExFrom.Replace (path, patternTo)) else None) + match matched with + | Some url -> ctx.Response.Redirect (url, permanent = true) + | None -> return! next.Invoke ctx + } + + open System open BitBadger.Npgsql.FSharp.Documents open Microsoft.Extensions.DependencyInjection @@ -207,6 +231,7 @@ let main args = let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict)) let _ = app.UseMiddleware () + let _ = app.UseMiddleware () let _ = app.UseAuthentication () let _ = app.UseStaticFiles () let _ = app.UseRouting () -- 2.45.1 From f43c431278a6efbad7472184088cd3b25ba71c15 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Mon, 31 Jul 2023 20:56:27 -0400 Subject: [PATCH 009/123] Verify regex rules work correctly (#39) - Remove debug for plain-text rules - Update htmx libs to 1.9.4 --- src/MyWebLog/MyWebLog.fsproj | 4 ++-- src/MyWebLog/Program.fs | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/MyWebLog/MyWebLog.fsproj b/src/MyWebLog/MyWebLog.fsproj index 037c8b9..7b21a1c 100644 --- a/src/MyWebLog/MyWebLog.fsproj +++ b/src/MyWebLog/MyWebLog.fsproj @@ -26,8 +26,8 @@ - - + + diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index 2a94084..a48fc2e 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -40,7 +40,6 @@ type RedirectRuleMiddleware (next : RequestDelegate, log : ILogger List.tryPick (fun rule -> match rule with | WebLogCache.CachedRedirectRule.Text (urlFrom, urlTo) -> - log.LogInformation $"Checking {path} against from={urlFrom} and to={urlTo}" if ciEquals path urlFrom then Some urlTo else None | WebLogCache.CachedRedirectRule.RegEx (regExFrom, patternTo) -> if regExFrom.IsMatch path then Some (regExFrom.Replace (path, patternTo)) else None) -- 2.45.1 From 79c304493c839865a8b71a9a129e9d077019d329 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Mon, 31 Jul 2023 21:28:02 -0400 Subject: [PATCH 010/123] Add doc link to redirect page (#39) - Tweak redirect list style - Add rules to v2.0 backup restorations --- src/MyWebLog/Maintenance.fs | 6 +++++- src/admin-theme/redirect-list.liquid | 17 +++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/MyWebLog/Maintenance.fs b/src/MyWebLog/Maintenance.fs index 544de4f..8413504 100644 --- a/src/MyWebLog/Maintenance.fs +++ b/src/MyWebLog/Maintenance.fs @@ -384,7 +384,11 @@ module Backup = // Restore web log data printfn "- Restoring web log..." - do! data.WebLog.Add restore.WebLog + // v2.0 backups will not have redirect rules; fix that if restoring to v2.1 or later + let webLog = + if isNull (box restore.WebLog.RedirectRules) then { restore.WebLog with RedirectRules = [] } + else restore.WebLog + do! data.WebLog.Add webLog printfn "- Restoring users..." do! data.WebLogUser.Restore restore.Users diff --git a/src/admin-theme/redirect-list.liquid b/src/admin-theme/redirect-list.liquid index 232ead0..721c862 100644 --- a/src/admin-theme/redirect-list.liquid +++ b/src/admin-theme/redirect-list.liquid @@ -18,9 +18,9 @@ {% if redir_count > 0 -%}
-
From
-
To
-
RegEx?
+
From
+
To
+
RegEx?
@@ -29,7 +29,7 @@ {% for redir in redirections -%} {%- assign redir_id = "redir_" | append: forloop.index0 -%}
-
+
{{ redir.from }}
{%- assign redir_url = "admin/settings/redirect-rules/" | append: forloop.index0 -%} @@ -55,8 +55,8 @@
-
{{ redir.to }}
-
{% if redir.is_regex %}Yes{% else %}No{% endif %}
+
{{ redir.to }}
+
{% if redir.is_regex %}Yes{% else %}No{% endif %}
{%- endfor %} @@ -68,4 +68,9 @@
+

+ This is an advanced feature; please + read and understand the documentation on this feature before adding rules. +

-- 2.45.1 From b84a0f711f6c7822cbd619003a568a8d6e511aec Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Mon, 31 Jul 2023 22:17:14 -0400 Subject: [PATCH 011/123] Add chapter data type (#6) --- src/MyWebLog.Data/SQLiteData.fs | 2 ++ src/MyWebLog.Domain/SupportTypes.fs | 39 +++++++++++++++++++++++++++++ src/MyWebLog.Domain/ViewModels.fs | 1 + 3 files changed, 42 insertions(+) diff --git a/src/MyWebLog.Data/SQLiteData.fs b/src/MyWebLog.Data/SQLiteData.fs index 89caa0c..c8248a9 100644 --- a/src/MyWebLog.Data/SQLiteData.fs +++ b/src/MyWebLog.Data/SQLiteData.fs @@ -318,6 +318,8 @@ type SQLiteData (conn : SqliteConnection, log : ILogger, ser : JsonS Subtitle = Map.tryString "subtitle" epRdr Explicit = Map.tryString "explicit" epRdr |> Option.map ExplicitRating.parse + Chapters = Map.tryString "chapters" epRdr + |> Option.map (Utils.deserialize ser) ChapterFile = Map.tryString "chapter_file" epRdr ChapterType = Map.tryString "chapter_type" epRdr TranscriptUrl = Map.tryString "transcript_url" epRdr diff --git a/src/MyWebLog.Domain/SupportTypes.fs b/src/MyWebLog.Domain/SupportTypes.fs index 1ece831..fb277ba 100644 --- a/src/MyWebLog.Domain/SupportTypes.fs +++ b/src/MyWebLog.Domain/SupportTypes.fs @@ -161,6 +161,41 @@ module ExplicitRating = | x -> raise (invalidArg "rating" $"{x} is not a valid explicit rating") +/// A location (specified by Podcast Index) +type Location = + { /// The name of the location (free-form text) + Name : string + + /// A geographic coordinate string (RFC 5870) + Geo : string option + + /// An OpenStreetMap query + Osm : string option + } + + +/// A chapter in a podcast episode +type Chapter = + { /// The start time for the chapter + StartTime : Duration + + /// The title for this chapter + Title : string option + + /// A URL for an image for this chapter + ImageUrl : string option + + /// Whether this chapter is hidden + IsHidden : bool option + + /// The episode end time for the chapter + EndTime : Duration option + + /// A location that applies to a chapter + Location : Location option + } + + open NodaTime.Text /// A podcast episode @@ -186,6 +221,9 @@ type Episode = /// This episode's explicit rating (overrides podcast rating if present) Explicit : ExplicitRating option + /// Chapters for this episode + Chapters : Chapter list option + /// A link to a chapter file ChapterFile : string option @@ -229,6 +267,7 @@ module Episode = ImageUrl = None Subtitle = None Explicit = None + Chapters = None ChapterFile = None ChapterType = None TranscriptUrl = None diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index 61ece89..86f00a7 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -788,6 +788,7 @@ type EditPostModel = ImageUrl = noneIfBlank this.ImageUrl Subtitle = noneIfBlank this.Subtitle Explicit = noneIfBlank this.Explicit |> Option.map ExplicitRating.parse + Chapters = match post.Episode with Some e -> e.Chapters | None -> None ChapterFile = noneIfBlank this.ChapterFile ChapterType = noneIfBlank this.ChapterType TranscriptUrl = noneIfBlank this.TranscriptUrl -- 2.45.1 From 715e545ed57c540d1a6866326f8069124e55833e Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Wed, 13 Dec 2023 14:17:53 -0500 Subject: [PATCH 012/123] Update deps - Add .NET 8 target - Remove ID param for Postgres doc calls --- src/Directory.Build.props | 2 +- src/MyWebLog.Data/MyWebLog.Data.fsproj | 16 ++++++---------- .../Postgres/PostgresCategoryData.fs | 2 +- src/MyWebLog.Data/Postgres/PostgresPageData.fs | 2 +- src/MyWebLog.Data/Postgres/PostgresPostData.fs | 2 +- src/MyWebLog.Data/Postgres/PostgresTagMapData.fs | 2 +- src/MyWebLog.Data/Postgres/PostgresThemeData.fs | 2 +- src/MyWebLog.Data/Postgres/PostgresWebLogData.fs | 2 +- .../Postgres/PostgresWebLogUserData.fs | 2 +- src/MyWebLog/MyWebLog.fsproj | 1 - 10 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index e376d97..548e37b 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,6 +1,6 @@ - net6.0;net7.0 + net6.0;net7.0;net8.0 embedded 2.1.0.0 2.1.0.0 diff --git a/src/MyWebLog.Data/MyWebLog.Data.fsproj b/src/MyWebLog.Data/MyWebLog.Data.fsproj index 6a6ae75..707825c 100644 --- a/src/MyWebLog.Data/MyWebLog.Data.fsproj +++ b/src/MyWebLog.Data/MyWebLog.Data.fsproj @@ -5,21 +5,17 @@ - - - - + + + + - - + + - - - - diff --git a/src/MyWebLog.Data/Postgres/PostgresCategoryData.fs b/src/MyWebLog.Data/Postgres/PostgresCategoryData.fs index 244faed..5b703fa 100644 --- a/src/MyWebLog.Data/Postgres/PostgresCategoryData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresCategoryData.fs @@ -122,7 +122,7 @@ type PostgresCategoryData (log : ILogger) = /// Save a category let save (cat : Category) = backgroundTask { log.LogTrace "Category.save" - do! save Table.Category (CategoryId.toString cat.Id) cat + do! save Table.Category cat } /// Restore categories from a backup diff --git a/src/MyWebLog.Data/Postgres/PostgresPageData.fs b/src/MyWebLog.Data/Postgres/PostgresPageData.fs index faa4c79..6feb078 100644 --- a/src/MyWebLog.Data/Postgres/PostgresPageData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresPageData.fs @@ -145,7 +145,7 @@ type PostgresPageData (log : ILogger) = let save (page : Page) = backgroundTask { log.LogTrace "Page.save" let! oldPage = findFullById page.Id page.WebLogId - do! save Table.Page (PageId.toString page.Id) { page with Revisions = [] } + do! save Table.Page { page with Revisions = [] } do! updatePageRevisions page.Id (match oldPage with Some p -> p.Revisions | None -> []) page.Revisions () } diff --git a/src/MyWebLog.Data/Postgres/PostgresPostData.fs b/src/MyWebLog.Data/Postgres/PostgresPostData.fs index d3791de..ac676e7 100644 --- a/src/MyWebLog.Data/Postgres/PostgresPostData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresPostData.fs @@ -175,7 +175,7 @@ type PostgresPostData (log : ILogger) = let save (post : Post) = backgroundTask { log.LogTrace "Post.save" let! oldPost = findFullById post.Id post.WebLogId - do! save Table.Post (PostId.toString post.Id) { post with Revisions = [] } + do! save Table.Post { post with Revisions = [] } do! updatePostRevisions post.Id (match oldPost with Some p -> p.Revisions | None -> []) post.Revisions } diff --git a/src/MyWebLog.Data/Postgres/PostgresTagMapData.fs b/src/MyWebLog.Data/Postgres/PostgresTagMapData.fs index 6c0aa52..210dc14 100644 --- a/src/MyWebLog.Data/Postgres/PostgresTagMapData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresTagMapData.fs @@ -46,7 +46,7 @@ type PostgresTagMapData (log : ILogger) = /// Save a tag mapping let save (tagMap : TagMap) = - save Table.TagMap (TagMapId.toString tagMap.Id) tagMap + save Table.TagMap tagMap /// Restore tag mappings from a backup let restore (tagMaps : TagMap list) = backgroundTask { diff --git a/src/MyWebLog.Data/Postgres/PostgresThemeData.fs b/src/MyWebLog.Data/Postgres/PostgresThemeData.fs index 00af329..cf3a569 100644 --- a/src/MyWebLog.Data/Postgres/PostgresThemeData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresThemeData.fs @@ -47,7 +47,7 @@ type PostgresThemeData (log : ILogger) = /// Save a theme let save (theme : Theme) = log.LogTrace "Theme.save" - save Table.Theme (ThemeId.toString theme.Id) theme + save Table.Theme theme interface IThemeData with member _.All () = all () diff --git a/src/MyWebLog.Data/Postgres/PostgresWebLogData.fs b/src/MyWebLog.Data/Postgres/PostgresWebLogData.fs index ef6020c..f583cc2 100644 --- a/src/MyWebLog.Data/Postgres/PostgresWebLogData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresWebLogData.fs @@ -11,7 +11,7 @@ type PostgresWebLogData (log : ILogger) = /// Add a web log let add (webLog : WebLog) = log.LogTrace "WebLog.add" - insert Table.WebLog (WebLogId.toString webLog.Id) webLog + insert Table.WebLog webLog /// Retrieve all web logs let all () = diff --git a/src/MyWebLog.Data/Postgres/PostgresWebLogUserData.fs b/src/MyWebLog.Data/Postgres/PostgresWebLogUserData.fs index 80eeee3..32b912e 100644 --- a/src/MyWebLog.Data/Postgres/PostgresWebLogUserData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresWebLogUserData.fs @@ -85,7 +85,7 @@ type PostgresWebLogUserData (log : ILogger) = /// Save a user let save (user : WebLogUser) = log.LogTrace "WebLogUser.save" - save Table.WebLogUser (WebLogUserId.toString user.Id) user + save Table.WebLogUser user interface IWebLogUserData with member _.Add user = save user diff --git a/src/MyWebLog/MyWebLog.fsproj b/src/MyWebLog/MyWebLog.fsproj index 7b21a1c..a270487 100644 --- a/src/MyWebLog/MyWebLog.fsproj +++ b/src/MyWebLog/MyWebLog.fsproj @@ -31,7 +31,6 @@ - -- 2.45.1 From ec2d43acde8427c5a980678ec50a7ce3fffa3636 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Wed, 13 Dec 2023 15:43:35 -0500 Subject: [PATCH 013/123] WIP on SQLite/JSON data --- src/MyWebLog.Data/SQLite/Helpers.fs | 57 +++ src/MyWebLog.Data/SQLiteData.fs | 195 +++------ src/MyWebLog.Domain/DataTypes.fs | 637 ++++++++++++++-------------- src/MyWebLog.Domain/SupportTypes.fs | 515 +++++++++++----------- src/MyWebLog.Domain/ViewModels.fs | 259 +++++------ src/MyWebLog/Caches.fs | 2 +- src/MyWebLog/Handlers/Page.fs | 2 +- src/MyWebLog/Handlers/Post.fs | 2 +- src/MyWebLog/Handlers/Routes.fs | 2 +- 9 files changed, 819 insertions(+), 852 deletions(-) diff --git a/src/MyWebLog.Data/SQLite/Helpers.fs b/src/MyWebLog.Data/SQLite/Helpers.fs index 5224674..2a4f06a 100644 --- a/src/MyWebLog.Data/SQLite/Helpers.fs +++ b/src/MyWebLog.Data/SQLite/Helpers.fs @@ -2,6 +2,63 @@ [] module MyWebLog.Data.SQLite.Helpers +/// The table names used in the SQLite implementation +[] +module Table = + + /// Categories + [] + let Category = "category" + + /// Database Version + [] + let DbVersion = "db_version" + + /// Pages + [] + let Page = "page" + + /// Page Revisions + [] + let PageRevision = "page_revision" + + /// Posts + [] + let Post = "post" + + /// Post Comments + [] + let PostComment = "post_comment" + + /// Post Revisions + [] + let PostRevision = "post_revision" + + /// Tag/URL Mappings + [] + let TagMap = "tag_map" + + /// Themes + [] + let Theme = "theme" + + /// Theme Assets + [] + let ThemeAsset = "theme_asset" + + /// Uploads + [] + let Upload = "upload" + + /// Web Logs + [] + let WebLog = "web_log" + + /// Users + [] + let WebLogUser = "web_log_user" + + open System open Microsoft.Data.Sqlite open MyWebLog diff --git a/src/MyWebLog.Data/SQLiteData.fs b/src/MyWebLog.Data/SQLiteData.fs index c8248a9..d1a3aaf 100644 --- a/src/MyWebLog.Data/SQLiteData.fs +++ b/src/MyWebLog.Data/SQLiteData.fs @@ -27,17 +27,9 @@ type SQLiteData (conn : SqliteConnection, log : ILogger, ser : JsonS not (List.contains table tables) seq { // Theme tables - if needsTable "theme" then - "CREATE TABLE theme ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - version TEXT NOT NULL)" - if needsTable "theme_template" then - "CREATE TABLE theme_template ( - theme_id TEXT NOT NULL REFERENCES theme (id), - name TEXT NOT NULL, - template TEXT NOT NULL, - PRIMARY KEY (theme_id, name))" + if needsTable Table.Theme then + $"CREATE TABLE {Table.Theme} (data TEXT NOT NULL); + CREATE UNIQUE INDEX idx_{Table.Theme}_key ON {Table.Theme} (data ->> 'Id')"; if needsTable "theme_asset" then "CREATE TABLE theme_asset ( theme_id TEXT NOT NULL REFERENCES theme (id), @@ -46,139 +38,54 @@ type SQLiteData (conn : SqliteConnection, log : ILogger, ser : JsonS data BLOB NOT NULL, PRIMARY KEY (theme_id, path))" - // Web log tables - if needsTable "web_log" then - "CREATE TABLE web_log ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - slug TEXT NOT NULL, - subtitle TEXT, - default_page TEXT NOT NULL, - posts_per_page INTEGER NOT NULL, - theme_id TEXT NOT NULL REFERENCES theme (id), - url_base TEXT NOT NULL, - time_zone TEXT NOT NULL, - auto_htmx INTEGER NOT NULL DEFAULT 0, - uploads TEXT NOT NULL, - is_feed_enabled INTEGER NOT NULL DEFAULT 0, - feed_name TEXT NOT NULL, - items_in_feed INTEGER, - is_category_enabled INTEGER NOT NULL DEFAULT 0, - is_tag_enabled INTEGER NOT NULL DEFAULT 0, - copyright TEXT, - redirect_rules TEXT NOT NULL DEFAULT '[]'); - CREATE INDEX web_log_theme_idx ON web_log (theme_id)" - if needsTable "web_log_feed" then - "CREATE TABLE web_log_feed ( - id TEXT PRIMARY KEY, - web_log_id TEXT NOT NULL REFERENCES web_log (id), - source TEXT NOT NULL, - path TEXT NOT NULL, - podcast TEXT); - CREATE INDEX web_log_feed_web_log_idx ON web_log_feed (web_log_id)" + // Web log table + if needsTable Table.WebLog then + $"CREATE TABLE {Table.WebLog} (data TEXT NOT NULL); + CREATE UNIQUE INDEX idx_{Table.WebLog}_key ON {Table.WebLog} (data ->> 'Id')" // Category table - if needsTable "category" then - "CREATE TABLE category ( - id TEXT PRIMARY KEY, - web_log_id TEXT NOT NULL REFERENCES web_log (id), - name TEXT NOT NULL, - slug TEXT NOT NULL, - description TEXT, - parent_id TEXT); - CREATE INDEX category_web_log_idx ON category (web_log_id)" + if needsTable Table.Category then + $"CREATE TABLE {Table.Category} (data TEXT NOT NULL); + CREATE UNIQUE INDEX idx_{Table.Category}_key ON {Table.Category} (data -> 'Id'); + CREATE INDEX idx_{Table.Category}_web_log ON {Table.Category} (data ->> 'WebLogId')" // Web log user table - if needsTable "web_log_user" then - "CREATE TABLE web_log_user ( - id TEXT PRIMARY KEY, - web_log_id TEXT NOT NULL REFERENCES web_log (id), - email TEXT NOT NULL, - first_name TEXT NOT NULL, - last_name TEXT NOT NULL, - preferred_name TEXT NOT NULL, - password_hash TEXT NOT NULL, - url TEXT, - access_level TEXT NOT NULL, - created_on TEXT NOT NULL, - last_seen_on TEXT); - CREATE INDEX web_log_user_web_log_idx ON web_log_user (web_log_id); - CREATE INDEX web_log_user_email_idx ON web_log_user (web_log_id, email)" + if needsTable Table.WebLogUser then + $"CREATE TABLE web_log_user (data TEXT NOT NULL); + CREATE UNIQUE INDEX idx_{Table.WebLogUser}_key ON {Table.WebLogUser} (data ->> 'Id'); + CREATE INDEX idx_{Table.WebLogUser}_email ON {Table.WebLogUser} (data ->> 'WebLogId', data ->> 'Email')" // Page tables - if needsTable "page" then - "CREATE TABLE page ( - id TEXT PRIMARY KEY, - web_log_id TEXT NOT NULL REFERENCES web_log (id), - author_id TEXT NOT NULL REFERENCES web_log_user (id), - title TEXT NOT NULL, - permalink TEXT NOT NULL, - published_on TEXT NOT NULL, - updated_on TEXT NOT NULL, - is_in_page_list INTEGER NOT NULL DEFAULT 0, - template TEXT, - page_text TEXT NOT NULL, - meta_items TEXT); - CREATE INDEX page_web_log_idx ON page (web_log_id); - CREATE INDEX page_author_idx ON page (author_id); - CREATE INDEX page_permalink_idx ON page (web_log_id, permalink)" - if needsTable "page_permalink" then - "CREATE TABLE page_permalink ( - page_id TEXT NOT NULL REFERENCES page (id), - permalink TEXT NOT NULL, - PRIMARY KEY (page_id, permalink))" - if needsTable "page_revision" then + if needsTable Table.Page then + $"CREATE TABLE {Table.Page} (data TEXT NOT NULL); + CREATE UNIQUE INDEX idx_{Table.Page}_key ON {Table.Page} (data ->> 'Id'); + CREATE INDEX idx_{Table.Page}_author ON {Table.Page} (data ->> 'AuthorId'); + CREATE INDEX idx_{Table.Page}_permalink ON {Table.Page} (data ->> 'WebLogId', data ->> 'Permalink')" + if needsTable Table.PageRevision then "CREATE TABLE page_revision ( - page_id TEXT NOT NULL REFERENCES page (id), + page_id TEXT NOT NULL, as_of TEXT NOT NULL, revision_text TEXT NOT NULL, PRIMARY KEY (page_id, as_of))" // Post tables - if needsTable "post" then - "CREATE TABLE post ( - id TEXT PRIMARY KEY, - web_log_id TEXT NOT NULL REFERENCES web_log (id), - author_id TEXT NOT NULL REFERENCES web_log_user (id), - status TEXT NOT NULL, - title TEXT NOT NULL, - permalink TEXT NOT NULL, - published_on TEXT, - updated_on TEXT NOT NULL, - template TEXT, - post_text TEXT NOT NULL, - meta_items TEXT, - episode TEXT); - CREATE INDEX post_web_log_idx ON post (web_log_id); - CREATE INDEX post_author_idx ON post (author_id); - CREATE INDEX post_status_idx ON post (web_log_id, status, updated_on); - CREATE INDEX post_permalink_idx ON post (web_log_id, permalink)" - if needsTable "post_category" then - "CREATE TABLE post_category ( - post_id TEXT NOT NULL REFERENCES post (id), - category_id TEXT NOT NULL REFERENCES category (id), - PRIMARY KEY (post_id, category_id)); - CREATE INDEX post_category_category_idx ON post_category (category_id)" - if needsTable "post_tag" then - "CREATE TABLE post_tag ( - post_id TEXT NOT NULL REFERENCES post (id), - tag TEXT NOT NULL, - PRIMARY KEY (post_id, tag))" - if needsTable "post_permalink" then - "CREATE TABLE post_permalink ( - post_id TEXT NOT NULL REFERENCES post (id), - permalink TEXT NOT NULL, - PRIMARY KEY (post_id, permalink))" - if needsTable "post_revision" then - "CREATE TABLE post_revision ( - post_id TEXT NOT NULL REFERENCES post (id), + if needsTable Table.Post then + $"CREATE TABLE {Table.Post} (data TEXT NOT NULL); + CREATE UNIQUE INDEX idx_{Table.Post}_key ON {Table.Post} (data ->> 'Id'); + CREATE INDEX idx_{Table.Post}_author ON {Table.Post} (data ->> 'AuthorId'); + CREATE INDEX idx_{Table.Post}_status ON {Table.Post} (data ->> 'WebLogId', data ->> 'Status', data ->> 'UpdatedOn'); + CREATE INDEX idx_{Table.Post}_permalink ON {Table.Post} (data ->> 'WebLogId', data ->> 'Permalink')" + // TODO: index categories by post? + if needsTable Table.PostRevision then + $"CREATE TABLE {Table.PostRevision} ( + post_id TEXT NOT NULL, as_of TEXT NOT NULL, revision_text TEXT NOT NULL, PRIMARY KEY (post_id, as_of))" - if needsTable "post_comment" then - "CREATE TABLE post_comment ( + if needsTable Table.PostComment then + $"CREATE TABLE {Table.PostComment} ( id TEXT PRIMARY KEY, - post_id TEXT NOT NULL REFERENCES post(id), + post_id TEXT NOT NULL, in_reply_to_id TEXT, name TEXT NOT NULL, email TEXT NOT NULL, @@ -186,32 +93,28 @@ type SQLiteData (conn : SqliteConnection, log : ILogger, ser : JsonS status TEXT NOT NULL, posted_on TEXT NOT NULL, comment_text TEXT NOT NULL); - CREATE INDEX post_comment_post_idx ON post_comment (post_id)" + CREATE INDEX idx_{Table.PostComment}_post ON {Table.PostComment} (post_id)" // Tag map table - if needsTable "tag_map" then - "CREATE TABLE tag_map ( - id TEXT PRIMARY KEY, - web_log_id TEXT NOT NULL REFERENCES web_log (id), - tag TEXT NOT NULL, - url_value TEXT NOT NULL); - CREATE INDEX tag_map_web_log_idx ON tag_map (web_log_id)" + if needsTable Table.TagMap then + $"CREATE TABLE {Table.TagMap} (data TEXT NOT NULL); + CREATE UNIQUE INDEX idx_{Table.TagMap}_key ON {Table.TagMap} (data ->> 'Id'); + CREATE INDEX idx_{Table.TagMap}_tag ON {Table.TagMap} (data ->> 'WebLogId', data ->> 'UrlValue')"; // Uploaded file table - if needsTable "upload" then - "CREATE TABLE upload ( + if needsTable Table.Upload then + $"CREATE TABLE {Table.Upload} ( id TEXT PRIMARY KEY, - web_log_id TEXT NOT NULL REFERENCES web_log (id), + web_log_id TEXT NOT NULL, path TEXT NOT NULL, updated_on TEXT NOT NULL, data BLOB NOT NULL); - CREATE INDEX upload_web_log_idx ON upload (web_log_id); - CREATE INDEX upload_path_idx ON upload (web_log_id, path)" + CREATE INDEX idx_{Table.Upload}_path ON {Table.Upload} (web_log_id, path)" // Database version table - if needsTable "db_version" then - "CREATE TABLE db_version (id TEXT PRIMARY KEY); - INSERT INTO db_version VALUES ('v2')" + if needsTable Table.DbVersion then + $"CREATE TABLE {Table.DbVersion} (id TEXT PRIMARY KEY); + INSERT INTO {Table.DbVersion} VALUES ('v2.1')" } |> Seq.map (fun sql -> log.LogInformation $"Creating {(sql.Split ' ')[2]} table..." @@ -224,7 +127,7 @@ type SQLiteData (conn : SqliteConnection, log : ILogger, ser : JsonS /// Set the database version to the specified version let setDbVersion version = backgroundTask { use cmd = conn.CreateCommand () - cmd.CommandText <- $"DELETE FROM db_version; INSERT INTO db_version VALUES ('%s{version}')" + cmd.CommandText <- $"DELETE FROM {Table.DbVersion}; INSERT INTO {Table.DbVersion} VALUES ('%s{version}')" do! write cmd } @@ -600,7 +503,7 @@ type SQLiteData (conn : SqliteConnection, log : ILogger, ser : JsonS do! ensureTables () use cmd = conn.CreateCommand () - cmd.CommandText <- "SELECT id FROM db_version" + cmd.CommandText <- $"SELECT id FROM {Table.DbVersion}" use! rdr = cmd.ExecuteReaderAsync () do! migrate (if rdr.Read () then Some (Map.getString "id" rdr) else None) } diff --git a/src/MyWebLog.Domain/DataTypes.fs b/src/MyWebLog.Domain/DataTypes.fs index c547389..cae8b76 100644 --- a/src/MyWebLog.Domain/DataTypes.fs +++ b/src/MyWebLog.Domain/DataTypes.fs @@ -6,405 +6,404 @@ open NodaTime /// A category under which a post may be identified [] -type Category = - { /// The ID of the category - Id : CategoryId +type Category = { + /// The ID of the category + Id : CategoryId - /// The ID of the web log to which the category belongs - WebLogId : WebLogId + /// The ID of the web log to which the category belongs + WebLogId : WebLogId - /// The displayed name - Name : string + /// The displayed name + Name : string - /// The slug (used in category URLs) - Slug : string + /// The slug (used in category URLs) + Slug : string - /// A longer description of the category - Description : string option + /// A longer description of the category + Description : string option - /// The parent ID of this category (if a subcategory) - ParentId : CategoryId 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 - } + 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 +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 post to which this comment applies + PostId : PostId - /// The ID of the comment to which this comment is a reply - InReplyToId : CommentId option + /// The ID of the comment to which this comment is a reply + InReplyToId : CommentId option - /// The name of the commentor - Name : string + /// The name of the commentor + Name : string - /// The e-mail address of the commentor - Email : string + /// The e-mail address of the commentor + Email : string - /// The URL of the commentor's personal website - Url : string option + /// The URL of the commentor's personal website + Url : string option - /// The status of the comment - Status : CommentStatus + /// The status of the comment + Status : CommentStatus - /// When the comment was posted - PostedOn : Instant + /// When the comment was posted + PostedOn : Instant - /// The text of the comment - Text : string - } + /// 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 = Noda.epoch - Text = "" - } + let empty = { + Id = CommentId.empty + PostId = PostId.empty + InReplyToId = None + Name = "" + Email = "" + Url = None + Status = Pending + PostedOn = Noda.epoch + Text = "" + } /// A page (text not associated with a date/time) [] -type Page = - { /// The ID of this page - Id : PageId +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 web log to which this page belongs + WebLogId : WebLogId - /// The ID of the author of this page - AuthorId : WebLogUserId + /// The ID of the author of this page + AuthorId : WebLogUserId - /// The title of the page - Title : string + /// The title of the page + Title : string - /// The link at which this page is displayed - Permalink : Permalink + /// The link at which this page is displayed + Permalink : Permalink - /// When this page was published - PublishedOn : Instant + /// When this page was published + PublishedOn : Instant - /// When this page was last updated - UpdatedOn : Instant + /// When this page was last updated + UpdatedOn : Instant - /// Whether this page shows as part of the web log's navigation - IsInPageList : bool + /// Whether this page shows as part of the web log's navigation + IsInPageList : bool - /// The template to use when rendering this page - Template : string option + /// The template to use when rendering this page + Template : string option - /// The current text of the page - Text : string + /// 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 + /// 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 - } + /// 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 = Noda.epoch - UpdatedOn = Noda.epoch - IsInPageList = false - Template = None - Text = "" - Metadata = [] - PriorPermalinks = [] - Revisions = [] - } + let empty = { + Id = PageId.empty + WebLogId = WebLogId.empty + AuthorId = WebLogUserId.empty + Title = "" + Permalink = Permalink.empty + PublishedOn = Noda.epoch + UpdatedOn = Noda.epoch + IsInPageList = false + Template = None + Text = "" + Metadata = [] + PriorPermalinks = [] + Revisions = [] + } /// A web log post [] -type Post = - { /// The ID of this post - Id : PostId +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 web log to which this post belongs + WebLogId : WebLogId - /// The ID of the author of this post - AuthorId : WebLogUserId + /// The ID of the author of this post + AuthorId : WebLogUserId - /// The status - Status : PostStatus + /// The status + Status : PostStatus - /// The title - Title : string + /// The title + Title : string - /// The link at which the post resides - Permalink : Permalink + /// The link at which the post resides + Permalink : Permalink - /// The instant on which the post was originally published - PublishedOn : Instant option + /// The instant on which the post was originally published + PublishedOn : Instant option - /// The instant on which the post was last updated - UpdatedOn : Instant + /// The instant on which the post was last updated + UpdatedOn : Instant - /// 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 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 Ids of the categories to which this is assigned + CategoryIds : CategoryId list - /// The tags for the post - Tags : string list + /// The tags for the post + Tags : string list - /// Podcast episode information for this post - Episode : Episode option - - /// Metadata for the post - Metadata : MetaItem list - - /// Permalinks at which this post may have been previously served (useful for migrated content) - PriorPermalinks : Permalink list + /// Podcast episode information for this post + Episode : Episode option + + /// 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 - } + /// 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 = Noda.epoch - Text = "" - Template = None - CategoryIds = [] - Tags = [] - Episode = None - Metadata = [] - PriorPermalinks = [] - Revisions = [] - } + let empty = { + Id = PostId.empty + WebLogId = WebLogId.empty + AuthorId = WebLogUserId.empty + Status = Draft + Title = "" + Permalink = Permalink.empty + PublishedOn = None + UpdatedOn = Noda.epoch + Text = "" + Template = None + CategoryIds = [] + Tags = [] + Episode = None + 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 - } +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 = "" - } + 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 - } +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 = [] - } + 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 : Instant - - /// The data for the asset - Data : byte[] - } +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 : Instant + + /// The data for the asset + Data : byte[] +} /// Functions to support theme assets module ThemeAsset = /// An empty theme asset - let empty = - { Id = ThemeAssetId (ThemeId "", "") - UpdatedOn = Noda.epoch - Data = [||] - } + let empty = { + Id = ThemeAssetId (ThemeId "", "") + UpdatedOn = Noda.epoch + Data = [||] + } /// An uploaded file -type Upload = - { /// The ID of the upload - Id : UploadId - - /// The ID of the web log to which this upload belongs - WebLogId : WebLogId - - /// The link at which this upload is served - Path : Permalink - - /// The updated date/time for this upload - UpdatedOn : Instant - - /// The data for the upload - Data : byte[] - } +type Upload = { + /// The ID of the upload + Id : UploadId + + /// The ID of the web log to which this upload belongs + WebLogId : WebLogId + + /// The link at which this upload is served + Path : Permalink + + /// The updated date/time for this upload + UpdatedOn : Instant + + /// The data for the upload + Data : byte[] +} /// Functions to support uploaded files module Upload = /// An empty upload - let empty = - { Id = UploadId.empty - WebLogId = WebLogId.empty - Path = Permalink.empty - UpdatedOn = Noda.epoch - Data = [||] - } + let empty = { + Id = UploadId.empty + WebLogId = WebLogId.empty + Path = Permalink.empty + UpdatedOn = Noda.epoch + Data = [||] + } /// A web log [] -type WebLog = - { /// The ID of the web log - Id : WebLogId +type WebLog = { + /// The ID of the web log + Id : WebLogId - /// The name of the web log - Name : string + /// The name of the web log + Name : string - /// The slug of the web log - Slug : string - - /// A subtitle for the web log - Subtitle : string option + /// The slug of the web log + Slug : string + + /// A subtitle for the web log + Subtitle : string option - /// The default page ("posts" or a page Id) - DefaultPage : string + /// The default page ("posts" or a page Id) + DefaultPage : string - /// The number of posts to display on pages of posts - PostsPerPage : int + /// The number of posts to display on pages of posts + PostsPerPage : int - /// The ID of the theme (also the path within /themes) - ThemeId : ThemeId + /// The ID of the theme (also the path within /themes) + ThemeId : ThemeId - /// The URL base - UrlBase : 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 - - /// Where uploads are placed - Uploads : UploadDestination + /// 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 + + /// Where uploads are placed + Uploads : UploadDestination - /// Redirect rules for this weblog - RedirectRules : RedirectRule list - } + /// Redirect rules for this weblog + RedirectRules : RedirectRule list +} /// Functions to support web logs module WebLog = /// An empty web log - let empty = - { Id = WebLogId.empty - Name = "" - Slug = "" - Subtitle = None - DefaultPage = "" - PostsPerPage = 10 - ThemeId = ThemeId "default" - UrlBase = "" - TimeZone = "" - Rss = RssOptions.empty - AutoHtmx = false - Uploads = Database - RedirectRules = [] - } + let empty = { + Id = WebLogId.empty + Name = "" + Slug = "" + Subtitle = None + DefaultPage = "" + PostsPerPage = 10 + ThemeId = ThemeId "default" + UrlBase = "" + TimeZone = "" + Rss = RssOptions.empty + AutoHtmx = false + Uploads = Database + RedirectRules = [] + } /// 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 "" + $"{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 = @@ -418,71 +417,71 @@ module WebLog = /// Convert an Instant (UTC reference) to the web log's local date/time let localTime webLog (date : Instant) = match DateTimeZoneProviders.Tzdb[webLog.TimeZone] with - | null -> date.ToDateTimeUtc () - | tz -> date.InZone(tz).ToDateTimeUnspecified () + | null -> date.ToDateTimeUtc() + | tz -> date.InZone(tz).ToDateTimeUnspecified() /// A user of the web log [] -type WebLogUser = - { /// The ID of the user - Id : WebLogUserId +type WebLogUser = { + /// The ID of the user + Id : WebLogUserId - /// The ID of the web log to which this user belongs - WebLogId : WebLogId + /// The ID of the web log to which this user belongs + WebLogId : WebLogId - /// The user name (e-mail address) - Email : string + /// The user name (e-mail address) + Email : string - /// The user's first name - FirstName : string + /// The user's first name + FirstName : string - /// The user's last name - LastName : string + /// The user's last name + LastName : string - /// The user's preferred name - PreferredName : string + /// The user's preferred name + PreferredName : string - /// The hash of the user's password - PasswordHash : string + /// The hash of the user's password + PasswordHash : string - /// The URL of the user's personal site - Url : string option + /// The URL of the user's personal site + Url : string option - /// The user's access level - AccessLevel : AccessLevel - - /// When the user was created - CreatedOn : Instant - - /// When the user last logged on - LastSeenOn : Instant option - } + /// The user's access level + AccessLevel : AccessLevel + + /// When the user was created + CreatedOn : Instant + + /// When the user last logged on + LastSeenOn : Instant option +} /// Functions to support web log users module WebLogUser = /// An empty web log user - let empty = - { Id = WebLogUserId.empty - WebLogId = WebLogId.empty - Email = "" - FirstName = "" - LastName = "" - PreferredName = "" - PasswordHash = "" - Url = None - AccessLevel = Author - CreatedOn = Noda.epoch - LastSeenOn = None - } + let empty = { + Id = WebLogUserId.empty + WebLogId = WebLogId.empty + Email = "" + FirstName = "" + LastName = "" + PreferredName = "" + PasswordHash = "" + Url = None + AccessLevel = Author + CreatedOn = Noda.epoch + LastSeenOn = None + } /// 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 () + name.Trim() /// Does a user have the required access level? let hasAccess level user = diff --git a/src/MyWebLog.Domain/SupportTypes.fs b/src/MyWebLog.Domain/SupportTypes.fs index fb277ba..c3e0fe1 100644 --- a/src/MyWebLog.Domain/SupportTypes.fs +++ b/src/MyWebLog.Domain/SupportTypes.fs @@ -10,7 +10,7 @@ module private Helpers = /// Create a new ID (short GUID) // https://www.madskristensen.net/blog/A-shorter-and-URL-friendly-GUID let newId () = - Convert.ToBase64String(Guid.NewGuid().ToByteArray ()).Replace('/', '_').Replace('+', '-').Substring (0, 22) + Convert.ToBase64String(Guid.NewGuid().ToByteArray ()).Replace('/', '_').Replace('+', '-')[..22] /// Functions to support NodaTime manipulation @@ -21,19 +21,18 @@ module Noda = /// The Unix epoch let epoch = Instant.FromUnixTimeSeconds 0L - /// Truncate an instant to remove fractional seconds let toSecondsPrecision (value : Instant) = - Instant.FromUnixTimeSeconds (value.ToUnixTimeSeconds ()) + Instant.FromUnixTimeSeconds(value.ToUnixTimeSeconds()) /// The current Instant, with fractional seconds truncated - let now () = - toSecondsPrecision (clock.GetCurrentInstant ()) + let now = + clock.GetCurrentInstant >> toSecondsPrecision /// Convert a date/time to an Instant with whole seconds let fromDateTime (dt : DateTime) = - toSecondsPrecision (Instant.FromDateTimeUtc (DateTime (dt.Ticks, DateTimeKind.Utc))) + Instant.FromDateTimeUtc(DateTime(dt.Ticks, DateTimeKind.Utc)) |> toSecondsPrecision /// A user's access level @@ -94,7 +93,7 @@ module CategoryId = let toString = function CategoryId ci -> ci /// Create a new category ID - let create () = CategoryId (newId ()) + let create = newId >> CategoryId /// An identifier for a comment @@ -110,7 +109,7 @@ module CommentId = let toString = function CommentId ci -> ci /// Create a new comment ID - let create () = CommentId (newId ()) + let create = newId >> CommentId /// Statuses for post comments @@ -134,7 +133,7 @@ module CommentStatus = | "Approved" -> Approved | "Pending" -> Pending | "Spam" -> Spam - | it -> invalidOp $"{it} is not a valid post status" + | it -> invalidArg "status" $"{it} is not a valid comment status" /// Valid values for the iTunes explicit rating @@ -158,127 +157,127 @@ module ExplicitRating = | "yes" -> Yes | "no" -> No | "clean" -> Clean - | x -> raise (invalidArg "rating" $"{x} is not a valid explicit rating") + | x -> invalidArg "rating" $"{x} is not a valid explicit rating" /// A location (specified by Podcast Index) -type Location = - { /// The name of the location (free-form text) - Name : string +type Location = { + /// The name of the location (free-form text) + Name : string - /// A geographic coordinate string (RFC 5870) - Geo : string option + /// A geographic coordinate string (RFC 5870) + Geo : string option - /// An OpenStreetMap query - Osm : string option - } + /// An OpenStreetMap query + Osm : string option +} /// A chapter in a podcast episode -type Chapter = - { /// The start time for the chapter - StartTime : Duration +type Chapter = { + /// The start time for the chapter + StartTime : Duration - /// The title for this chapter - Title : string option + /// The title for this chapter + Title : string option - /// A URL for an image for this chapter - ImageUrl : string option + /// A URL for an image for this chapter + ImageUrl : string option - /// Whether this chapter is hidden - IsHidden : bool option + /// Whether this chapter is hidden + IsHidden : bool option - /// The episode end time for the chapter - EndTime : Duration option + /// The episode end time for the chapter + EndTime : Duration option - /// A location that applies to a chapter - Location : Location option - } + /// A location that applies to a chapter + Location : Location option +} open NodaTime.Text /// A podcast episode -type Episode = - { /// The URL to the media file for the episode (may be permalink) - Media : string - - /// The length of the media file, in bytes - Length : int64 - - /// The duration of the episode - Duration : Duration option - - /// The media type of the file (overrides podcast default if present) - MediaType : string option - - /// The URL to the image file for this episode (overrides podcast image if present, may be permalink) - ImageUrl : string option - - /// A subtitle for this episode - Subtitle : string option - - /// This episode's explicit rating (overrides podcast rating if present) - Explicit : ExplicitRating option - - /// Chapters for this episode - Chapters : Chapter list option +type Episode = { + /// The URL to the media file for the episode (may be permalink) + Media : string + + /// The length of the media file, in bytes + Length : int64 + + /// The duration of the episode + Duration : Duration option + + /// The media type of the file (overrides podcast default if present) + MediaType : string option + + /// The URL to the image file for this episode (overrides podcast image if present, may be permalink) + ImageUrl : string option + + /// A subtitle for this episode + Subtitle : string option + + /// This episode's explicit rating (overrides podcast rating if present) + Explicit : ExplicitRating option + + /// Chapters for this episode + Chapters : Chapter list option - /// A link to a chapter file - ChapterFile : string option - - /// The MIME type for the chapter file - ChapterType : string option - - /// The URL for the transcript of the episode (may be permalink) - TranscriptUrl : string option - - /// The MIME type of the transcript - TranscriptType : string option - - /// The language in which the transcript is written - TranscriptLang : string option - - /// If true, the transcript will be declared (in the feed) to be a captions file - TranscriptCaptions : bool option - - /// The season number (for serialized podcasts) - SeasonNumber : int option - - /// A description of the season - SeasonDescription : string option - - /// The episode number - EpisodeNumber : double option - - /// A description of the episode - EpisodeDescription : string option - } + /// A link to a chapter file + ChapterFile : string option + + /// The MIME type for the chapter file + ChapterType : string option + + /// The URL for the transcript of the episode (may be permalink) + TranscriptUrl : string option + + /// The MIME type of the transcript + TranscriptType : string option + + /// The language in which the transcript is written + TranscriptLang : string option + + /// If true, the transcript will be declared (in the feed) to be a captions file + TranscriptCaptions : bool option + + /// The season number (for serialized podcasts) + SeasonNumber : int option + + /// A description of the season + SeasonDescription : string option + + /// The episode number + EpisodeNumber : double option + + /// A description of the episode + EpisodeDescription : string option +} /// Functions to support episodes module Episode = /// An empty episode - let empty = - { Media = "" - Length = 0L - Duration = None - MediaType = None - ImageUrl = None - Subtitle = None - Explicit = None - Chapters = None - ChapterFile = None - ChapterType = None - TranscriptUrl = None - TranscriptType = None - TranscriptLang = None - TranscriptCaptions = None - SeasonNumber = None - SeasonDescription = None - EpisodeNumber = None - EpisodeDescription = None - } + let empty = { + Media = "" + Length = 0L + Duration = None + MediaType = None + ImageUrl = None + Subtitle = None + Explicit = None + Chapters = None + ChapterFile = None + ChapterType = None + TranscriptUrl = None + TranscriptType = None + TranscriptLang = None + TranscriptCaptions = None + SeasonNumber = None + SeasonDescription = None + EpisodeNumber = None + EpisodeDescription = None + } /// Format a duration for an episode let formatDuration ep = @@ -299,7 +298,7 @@ type MarkupText = module MarkupText = /// Pipeline with most extensions enabled - let private _pipeline = MarkdownPipelineBuilder().UseSmartyPants().UseAdvancedExtensions().UseColorCode().Build () + let private _pipeline = MarkdownPipelineBuilder().UseSmartyPants().UseAdvancedExtensions().UseColorCode().Build() /// Get the source type for the markup text let sourceType = function Markdown _ -> "Markdown" | Html _ -> "HTML" @@ -311,25 +310,25 @@ module MarkupText = 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 + 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 when text.StartsWith "Markdown: " -> Markdown text[10..] + | text when text.StartsWith "HTML: " -> Html text[6..] | text -> invalidOp $"Cannot derive type of text ({text})" /// An item of metadata [] -type MetaItem = - { /// The name of the metadata value - Name : string - - /// The metadata value - Value : string - } +type MetaItem = { + /// The name of the metadata value + Name : string + + /// The metadata value + Value : string +} /// Functions to support metadata items module MetaItem = @@ -340,22 +339,20 @@ module MetaItem = /// A revision of a page or post [] -type Revision = - { /// When this revision was saved - AsOf : Instant +type Revision = { + /// When this revision was saved + AsOf : Instant - /// The text of the revision - Text : MarkupText - } + /// The text of the revision + Text : MarkupText +} /// Functions to support revisions module Revision = /// An empty revision let empty = - { AsOf = Noda.epoch - Text = Html "" - } + { AsOf = Noda.epoch; Text = Html "" } /// A permanent link @@ -384,7 +381,7 @@ module PageId = let toString = function PageId pi -> pi /// Create a new page ID - let create () = PageId (newId ()) + let create = newId >> PageId /// PodcastIndex.org podcast:medium allowed values @@ -421,7 +418,7 @@ module PodcastMedium = | "audiobook" -> Audiobook | "newsletter" -> Newsletter | "blog" -> Blog - | it -> invalidOp $"{it} is not a valid podcast medium" + | it -> invalidArg "medium" $"{it} is not a valid podcast medium" /// Statuses for posts @@ -442,7 +439,7 @@ module PostStatus = match value with | "Draft" -> Draft | "Published" -> Published - | it -> invalidOp $"{it} is not a valid post status" + | it -> invalidArg "status" $"{it} is not a valid post status" /// An identifier for a post @@ -458,30 +455,30 @@ module PostId = let toString = function PostId pi -> pi /// Create a new post ID - let create () = PostId (newId ()) + let create = newId >> PostId /// A redirection for a previously valid URL -type RedirectRule = - { /// The From string or pattern - From : string - - /// The To string or pattern - To : string - - /// Whether to use regular expressions on this rule - IsRegex : bool - } +type RedirectRule = { + /// The From string or pattern + From : string + + /// The To string or pattern + To : string + + /// Whether to use regular expressions on this rule + IsRegex : bool +} /// Functions to support redirect rules module RedirectRule = /// An empty redirect rule - let empty = - { From = "" - To = "" - IsRegex = false - } + let empty = { + From = "" + To = "" + IsRegex = false + } /// An identifier for a custom feed @@ -497,7 +494,7 @@ module CustomFeedId = let toString = function CustomFeedId pi -> pi /// Create a new custom feed ID - let create () = CustomFeedId (newId ()) + let create = newId >> CustomFeedId /// The source for a custom feed @@ -525,122 +522,122 @@ module CustomFeedSource = /// 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 Apple Podcasts (iTunes) under which this podcast is categorized - AppleCategory : string - - /// A further refinement of the categorization of this podcast (Apple Podcasts/iTunes field / values) - AppleSubcategory : 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 GUID for this podcast - PodcastGuid : Guid option - - /// A URL at which information on supporting the podcast may be found (supports permalinks) - FundingUrl : string option - - /// The text to be displayed in the funding item within the feed - FundingText : string option - - /// The medium (what the podcast IS, not what it is ABOUT) - Medium : PodcastMedium option - } +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 Apple Podcasts (iTunes) under which this podcast is categorized + AppleCategory : string + + /// A further refinement of the categorization of this podcast (Apple Podcasts/iTunes field / values) + AppleSubcategory : 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 GUID for this podcast + PodcastGuid : Guid option + + /// A URL at which information on supporting the podcast may be found (supports permalinks) + FundingUrl : string option + + /// The text to be displayed in the funding item within the feed + FundingText : string option + + /// The medium (what the podcast IS, not what it is ABOUT) + Medium : PodcastMedium 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 - } +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 - } + 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 - IsFeedEnabled : 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 - IsCategoryEnabled : bool - - /// Whether feeds are enabled for all tags - IsTagEnabled : bool - - /// A copyright string to be placed in all feeds - Copyright : string option - - /// Custom feeds for this web log - CustomFeeds: CustomFeed list - } +type RssOptions = { + /// Whether the site feed of posts is enabled + IsFeedEnabled : 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 + IsCategoryEnabled : bool + + /// Whether feeds are enabled for all tags + IsTagEnabled : 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 = - { IsFeedEnabled = true - FeedName = "feed.xml" - ItemsInFeed = None - IsCategoryEnabled = true - IsTagEnabled = true - Copyright = None - CustomFeeds = [] - } + let empty = { + IsFeedEnabled = true + FeedName = "feed.xml" + ItemsInFeed = None + IsCategoryEnabled = true + IsTagEnabled = true + Copyright = None + CustomFeeds = [] + } /// An identifier for a tag mapping @@ -656,7 +653,7 @@ module TagMapId = let toString = function TagMapId tmi -> tmi /// Create a new tag mapping ID - let create () = TagMapId (newId ()) + let create = newId >> TagMapId /// An identifier for a theme (represents its path) @@ -683,22 +680,20 @@ module ThemeAssetId = /// A template for a theme -type ThemeTemplate = - { /// The name of the template - Name : string - - /// The text of the template - Text : string - } +type ThemeTemplate = { + /// The name of the template + Name : string + + /// The text of the template + Text : string +} /// Functions to support theme templates module ThemeTemplate = /// An empty theme template let empty = - { Name = "" - Text = "" - } + { Name = ""; Text = "" } /// Where uploads should be placed @@ -717,7 +712,7 @@ module UploadDestination = match value with | "Database" -> Database | "Disk" -> Disk - | it -> invalidOp $"{it} is not a valid upload destination" + | it -> invalidArg "destination" $"{it} is not a valid upload destination" /// An identifier for an upload @@ -733,7 +728,7 @@ module UploadId = let toString = function UploadId ui -> ui /// Create a new upload ID - let create () = UploadId (newId ()) + let create = newId >> UploadId /// An identifier for a web log @@ -749,7 +744,7 @@ module WebLogId = let toString = function WebLogId wli -> wli /// Create a new web log ID - let create () = WebLogId (newId ()) + let create = newId >> WebLogId @@ -766,6 +761,6 @@ module WebLogUserId = let toString = function WebLogUserId wli -> wli /// Create a new web log user ID - let create () = WebLogUserId (newId ()) + let create = newId >> WebLogUserId diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index 86f00a7..a0ebb3f 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -10,7 +10,7 @@ module private Helpers = /// Create a string option if a string is blank let noneIfBlank (it : string) = - match (defaultArg (Option.ofObj it) "").Trim () with "" -> None | trimmed -> Some trimmed + match (defaultArg (Option.ofObj it) "").Trim() with "" -> None | trimmed -> Some trimmed /// Helper functions that are needed outside this file @@ -26,67 +26,70 @@ module PublicHelpers = /// The model used to display the admin dashboard [] -type DashboardModel = - { /// The number of published posts - Posts : int +type DashboardModel = { + /// The number of published posts + Posts : int - /// The number of post drafts - Drafts : int + /// The number of post drafts + Drafts : int - /// The number of pages - Pages : int + /// The number of pages + Pages : int - /// The number of pages in the page list - ListedPages : int + /// The number of pages in the page list + ListedPages : int - /// The number of categories - Categories : int + /// The number of categories + Categories : int - /// The top-level categories - TopLevelCategories : int - } + /// The top-level categories + TopLevelCategories : int +} /// 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 - } +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 - } +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 +} + +/// Support functions for custom feed displays +module DisplayCustomFeed = /// Create a display version from a custom feed - static member fromFeed (cats : DisplayCategory[]) (feed : CustomFeed) : DisplayCustomFeed = + let fromFeed (cats : DisplayCategory[]) (feed : CustomFeed) : DisplayCustomFeed = let source = match feed.Source with | Category (CategoryId catId) -> $"Category: {(cats |> Array.find (fun cat -> cat.Id = catId)).Name}" @@ -133,7 +136,7 @@ type DisplayPage = } /// Create a minimal display page (no text or metadata) from a database page - static member fromPageMinimal webLog (page : Page) = + static member FromPageMinimal webLog (page : Page) = let pageId = PageId.toString page.Id { Id = pageId AuthorId = WebLogUserId.toString page.AuthorId @@ -148,7 +151,7 @@ type DisplayPage = } /// Create a display page from a database page - static member fromPage webLog (page : Page) = + static member FromPage webLog (page : Page) = let _, extra = WebLog.hostAndPath webLog let pageId = PageId.toString page.Id { Id = pageId @@ -166,20 +169,22 @@ type DisplayPage = /// Information about a revision used for display [] -type DisplayRevision = - { /// The as-of date/time for the revision - AsOf : DateTime - - /// The as-of date/time for the revision in the web log's local time zone - AsOfLocal : DateTime - - /// The format of the text of the revision - Format : string - } -with +type DisplayRevision = { + /// The as-of date/time for the revision + AsOf : DateTime + + /// The as-of date/time for the revision in the web log's local time zone + AsOfLocal : DateTime + + /// The format of the text of the revision + Format : string +} +/// Functions to support displaying revisions +module DisplayRevision = + /// Create a display revision from an actual revision - static member fromRevision webLog (rev : Revision) = + let fromRevision webLog (rev : Revision) = { AsOf = rev.AsOf.ToDateTimeUtc () AsOfLocal = WebLog.localTime webLog rev.AsOf Format = MarkupText.sourceType rev.Text @@ -190,29 +195,31 @@ open System.IO /// Information about a theme used for display [] -type DisplayTheme = - { /// The ID / path slug of the theme - Id : string - - /// The name of the theme - Name : string - - /// The version of the theme - Version : string - - /// How many templates are contained in the theme - TemplateCount : int - - /// Whether the theme is in use by any web logs - IsInUse : bool - - /// Whether the theme .zip file exists on the filesystem - IsOnDisk : bool - } -with +type DisplayTheme = { + /// The ID / path slug of the theme + Id : string + + /// The name of the theme + Name : string + + /// The version of the theme + Version : string + + /// How many templates are contained in the theme + TemplateCount : int + + /// Whether the theme is in use by any web logs + IsInUse : bool + + /// Whether the theme .zip file exists on the filesystem + IsOnDisk : bool +} + +/// Functions to support displaying themes +module DisplayTheme = /// Create a display theme from a theme - static member fromTheme inUseFunc (theme : Theme) = + let fromTheme inUseFunc (theme : Theme) = { Id = ThemeId.toString theme.Id Name = theme.Name Version = theme.Version @@ -224,25 +231,28 @@ with /// Information about an uploaded file used for display [] -type DisplayUpload = - { /// The ID of the uploaded file - Id : string - - /// The name of the uploaded file - Name : string - - /// The path at which the file is served - Path : string - - /// The date/time the file was updated - UpdatedOn : DateTime option - - /// The source for this file (created from UploadDestination DU) - Source : string - } +type DisplayUpload = { + /// The ID of the uploaded file + Id : string + + /// The name of the uploaded file + Name : string + + /// The path at which the file is served + Path : string + + /// The date/time the file was updated + UpdatedOn : DateTime option + + /// The source for this file (created from UploadDestination DU) + Source : string +} + +/// Functions to support displaying uploads +module DisplayUpload = /// Create a display uploaded file - static member fromUpload webLog source (upload : Upload) = + let fromUpload webLog source (upload : Upload) = let path = Permalink.toString upload.Path let name = Path.GetFileName path { Id = UploadId.toString upload.Id @@ -255,37 +265,40 @@ type DisplayUpload = /// View model to display a user's information [] -type DisplayUser = - { /// The ID of the user - Id : string +type DisplayUser = { + /// The ID of the user + Id : string - /// The user name (e-mail address) - Email : string + /// The user name (e-mail address) + Email : string - /// The user's first name - FirstName : string + /// The user's first name + FirstName : string - /// The user's last name - LastName : string + /// The user's last name + LastName : string - /// The user's preferred name - PreferredName : string + /// The user's preferred name + PreferredName : string - /// The URL of the user's personal site - Url : string + /// The URL of the user's personal site + Url : string - /// The user's access level - AccessLevel : string - - /// When the user was created - CreatedOn : DateTime - - /// When the user last logged on - LastSeenOn : Nullable - } + /// The user's access level + AccessLevel : string + + /// When the user was created + CreatedOn : DateTime + + /// When the user last logged on + LastSeenOn : Nullable +} + +/// Functions to support displaying a user's information +module DisplayUser = /// Construct a displayed user from a web log user - static member fromUser webLog (user : WebLogUser) = + let fromUser webLog (user : WebLogUser) = { Id = WebLogUserId.toString user.Id Email = user.Email FirstName = user.FirstName diff --git a/src/MyWebLog/Caches.fs b/src/MyWebLog/Caches.fs index 2b66b59..cfb0e0f 100644 --- a/src/MyWebLog/Caches.fs +++ b/src/MyWebLog/Caches.fs @@ -131,7 +131,7 @@ module PageListCache = let private fillPages (webLog : WebLog) pages = _cache[webLog.Id] <- pages - |> List.map (fun pg -> DisplayPage.fromPage webLog { pg with Text = "" }) + |> List.map (fun pg -> DisplayPage.FromPage webLog { pg with Text = "" }) |> Array.ofList /// Are there pages cached for this web log? diff --git a/src/MyWebLog/Handlers/Page.fs b/src/MyWebLog/Handlers/Page.fs index 6ddeae8..cfdebbd 100644 --- a/src/MyWebLog/Handlers/Page.fs +++ b/src/MyWebLog/Handlers/Page.fs @@ -15,7 +15,7 @@ let all pageNbr : HttpHandler = requireAccess Author >=> fun next ctx -> task { |> addToHash "pages" (pages |> Seq.ofList |> Seq.truncate 25 - |> Seq.map (DisplayPage.fromPageMinimal ctx.WebLog) + |> Seq.map (DisplayPage.FromPageMinimal ctx.WebLog) |> List.ofSeq) |> addToHash "page_nbr" pageNbr |> addToHash "prev_page" (if pageNbr = 2 then "" else $"/page/{pageNbr - 1}") diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index c39dc86..087f66c 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -200,7 +200,7 @@ let home : HttpHandler = fun next ctx -> task { | Some page -> return! hashForPage page.Title - |> addToHash "page" (DisplayPage.fromPage webLog page) + |> addToHash "page" (DisplayPage.FromPage webLog page) |> addToHash ViewContext.IsHome true |> themedView (defaultArg page.Template "single-page") next ctx | None -> return! Error.notFound next ctx diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index 3b8f74d..5c6d371 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -40,7 +40,7 @@ module CatchAll = debug (fun () -> "Found page by permalink") yield fun next ctx -> hashForPage page.Title - |> addToHash "page" (DisplayPage.fromPage webLog page) + |> addToHash "page" (DisplayPage.FromPage webLog page) |> addToHash ViewContext.IsPage true |> themedView (defaultArg page.Template "single-page") next ctx | None -> () -- 2.45.1 From 7071d606f11053fdf4e98b27bb18cb05577da274 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 14 Dec 2023 23:49:38 -0500 Subject: [PATCH 014/123] WIP on module/member conversion --- src/MyWebLog.Data/Converters.fs | 74 ++++--- .../Postgres/PostgresCategoryData.fs | 12 +- .../Postgres/PostgresPostData.fs | 2 +- src/MyWebLog.Data/SQLite/Helpers.fs | 2 +- .../SQLite/SQLiteCategoryData.fs | 14 +- src/MyWebLog.Data/SQLite/SQLitePostData.fs | 8 +- .../SQLite/SQLiteWebLogUserData.fs | 2 +- src/MyWebLog.Data/SQLiteData.fs | 4 +- src/MyWebLog.Data/Utils.fs | 16 +- src/MyWebLog.Domain/DataTypes.fs | 6 +- src/MyWebLog.Domain/SupportTypes.fs | 207 +++++++++--------- src/MyWebLog.Domain/ViewModels.fs | 34 +-- src/MyWebLog/Caches.fs | 4 +- src/MyWebLog/Handlers/Admin.fs | 2 +- src/MyWebLog/Handlers/Feed.fs | 87 ++++---- src/MyWebLog/Handlers/Helpers.fs | 6 +- src/MyWebLog/Handlers/Post.fs | 8 +- src/MyWebLog/Handlers/User.fs | 13 +- src/MyWebLog/Maintenance.fs | 4 +- 19 files changed, 250 insertions(+), 255 deletions(-) diff --git a/src/MyWebLog.Data/Converters.fs b/src/MyWebLog.Data/Converters.fs index 52a132c..0c3be03 100644 --- a/src/MyWebLog.Data/Converters.fs +++ b/src/MyWebLog.Data/Converters.fs @@ -9,20 +9,27 @@ 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) = + type CategoryIdConverter() = + inherit JsonConverter() + override _.WriteJson(writer: JsonWriter, value: CategoryId, _: JsonSerializer) = + writer.WriteValue value.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) = + type CommentIdConverter() = + inherit JsonConverter() + override _.WriteJson(writer: JsonWriter, value: CommentId, _: JsonSerializer) = + writer.WriteValue value.Value + override _.ReadJson(reader: JsonReader, _: Type, _: CommentId, _: bool, _: JsonSerializer) = (string >> CommentId) reader.Value + type CommentStatusConverter() = + inherit JsonConverter() + override _.WriteJson(writer: JsonWriter, value: CommentStatus, _: JsonSerializer) = + writer.WriteValue value.Value + override _.ReadJson(reader: JsonReader, _: Type, _: CommentStatus, _: bool, _: JsonSerializer) = + (string >> CommentStatus.Parse) reader.Value + type CustomFeedIdConverter () = inherit JsonConverter () override _.WriteJson (writer : JsonWriter, value : CustomFeedId, _ : JsonSerializer) = @@ -37,12 +44,12 @@ module Json = 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 ExplicitRatingConverter() = + inherit JsonConverter() + override _.WriteJson(writer: JsonWriter, value: ExplicitRating, _: JsonSerializer) = + writer.WriteValue value.Value + override _.ReadJson(reader: JsonReader, _: Type, _: ExplicitRating, _: bool, _: JsonSerializer) = + (string >> ExplicitRating.Parse) reader.Value type MarkupTextConverter () = inherit JsonConverter () @@ -128,27 +135,28 @@ module Json = /// Configure a serializer to use these converters let configure (ser : JsonSerializer) = // Our converters - [ CategoryIdConverter () :> JsonConverter - CommentIdConverter () - CustomFeedIdConverter () - CustomFeedSourceConverter () - ExplicitRatingConverter () - MarkupTextConverter () - PermalinkConverter () - PageIdConverter () - PodcastMediumConverter () - PostIdConverter () - TagMapIdConverter () - ThemeAssetIdConverter () - ThemeIdConverter () - UploadIdConverter () - WebLogIdConverter () - WebLogUserIdConverter () + [ CategoryIdConverter() :> JsonConverter + CommentIdConverter() + CommentStatusConverter() + CustomFeedIdConverter() + CustomFeedSourceConverter() + ExplicitRatingConverter() + MarkupTextConverter() + PermalinkConverter() + PageIdConverter() + PodcastMediumConverter() + PostIdConverter() + TagMapIdConverter() + ThemeAssetIdConverter() + ThemeIdConverter() + UploadIdConverter() + WebLogIdConverter() + WebLogUserIdConverter() ] |> List.iter ser.Converters.Add // NodaTime let _ = ser.ConfigureForNodaTime DateTimeZoneProviders.Tzdb // Handles DUs with no associated data, as well as option fields - ser.Converters.Add (CompactUnionJsonConverter ()) + ser.Converters.Add(CompactUnionJsonConverter()) ser.NullValueHandling <- NullValueHandling.Ignore ser.MissingMemberHandling <- MissingMemberHandling.Ignore ser diff --git a/src/MyWebLog.Data/Postgres/PostgresCategoryData.fs b/src/MyWebLog.Data/Postgres/PostgresCategoryData.fs index 5b703fa..60ef682 100644 --- a/src/MyWebLog.Data/Postgres/PostgresCategoryData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresCategoryData.fs @@ -65,7 +65,7 @@ type PostgresCategoryData (log : ILogger) = /// Find a category by its ID for the given web log let findById catId webLogId = log.LogTrace "Category.findById" - Document.findByIdAndWebLog Table.Category catId CategoryId.toString webLogId + Document.findByIdAndWebLog Table.Category catId (_.Value) webLogId /// Find all categories for the given web log let findByWebLog webLogId = @@ -74,7 +74,7 @@ type PostgresCategoryData (log : ILogger) = /// Create parameters for a category insert / update let catParameters (cat : Category) = - Query.docParameters (CategoryId.toString cat.Id) cat + Query.docParameters cat.Id.Value cat /// Delete a category let delete catId webLogId = backgroundTask { @@ -82,7 +82,7 @@ type PostgresCategoryData (log : ILogger) = match! findById catId webLogId with | Some cat -> // Reassign any children to the category's parent category - let! children = Find.byContains Table.Category {| ParentId = CategoryId.toString catId |} + let! children = Find.byContains Table.Category {| ParentId = catId.Value |} let hasChildren = not (List.isEmpty children) if hasChildren then let! _ = @@ -91,7 +91,7 @@ type PostgresCategoryData (log : ILogger) = |> Sql.executeTransactionAsync [ Query.Update.partialById Table.Category, children |> List.map (fun child -> [ - "@id", Sql.string (CategoryId.toString child.Id) + "@id", Sql.string child.Id.Value "@data", Query.jsonbDocParam {| ParentId = cat.ParentId |} ]) ] @@ -99,7 +99,7 @@ type PostgresCategoryData (log : ILogger) = // Delete the category off all posts where it is assigned let! posts = Custom.list $"SELECT data FROM {Table.Post} WHERE data -> '{nameof Post.empty.CategoryIds}' @> @id" - [ "@id", Query.jsonbDocParam [| CategoryId.toString catId |] ] fromData + [ "@id", Query.jsonbDocParam [| catId.Value |] ] fromData if not (List.isEmpty posts) then let! _ = Configuration.dataSource () @@ -114,7 +114,7 @@ type PostgresCategoryData (log : ILogger) = ] () // Delete the category itself - do! Delete.byId Table.Category (CategoryId.toString catId) + do! Delete.byId Table.Category catId.Value return if hasChildren then ReassignedChildCategories else CategoryDeleted | None -> return CategoryNotFound } diff --git a/src/MyWebLog.Data/Postgres/PostgresPostData.fs b/src/MyWebLog.Data/Postgres/PostgresPostData.fs index ac676e7..70a6c54 100644 --- a/src/MyWebLog.Data/Postgres/PostgresPostData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresPostData.fs @@ -106,7 +106,7 @@ type PostgresPostData (log : ILogger) = /// Get a page of categorized posts for the given web log (excludes revisions) let findPageOfCategorizedPosts webLogId categoryIds pageNbr postsPerPage = log.LogTrace "Post.findPageOfCategorizedPosts" - let catSql, catParam = arrayContains (nameof Post.empty.CategoryIds) CategoryId.toString categoryIds + let catSql, catParam = arrayContains (nameof Post.empty.CategoryIds) (_.Value) categoryIds Custom.list $"{selectWithCriteria Table.Post} AND {catSql} diff --git a/src/MyWebLog.Data/SQLite/Helpers.fs b/src/MyWebLog.Data/SQLite/Helpers.fs index 2a4f06a..08ab5a4 100644 --- a/src/MyWebLog.Data/SQLite/Helpers.fs +++ b/src/MyWebLog.Data/SQLite/Helpers.fs @@ -362,7 +362,7 @@ module Map = PreferredName = getString "preferred_name" rdr PasswordHash = getString "password_hash" rdr Url = tryString "url" rdr - AccessLevel = getString "access_level" rdr |> AccessLevel.parse + AccessLevel = getString "access_level" rdr |> AccessLevel.Parse CreatedOn = getInstant "created_on" rdr LastSeenOn = tryInstant "last_seen_on" rdr } diff --git a/src/MyWebLog.Data/SQLite/SQLiteCategoryData.fs b/src/MyWebLog.Data/SQLite/SQLiteCategoryData.fs index 75728b8..3caae20 100644 --- a/src/MyWebLog.Data/SQLite/SQLiteCategoryData.fs +++ b/src/MyWebLog.Data/SQLite/SQLiteCategoryData.fs @@ -10,12 +10,12 @@ type SQLiteCategoryData (conn : SqliteConnection) = /// 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 ("@id", cat.Id.Value) 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)) + cmd.Parameters.AddWithValue ("@parentId", maybe (cat.ParentId |> Option.map _.Value)) ] |> ignore /// Add a category @@ -101,10 +101,10 @@ type SQLiteCategoryData (conn : SqliteConnection) = |> Array.ofSeq } /// Find a category by its ID for the given web log - let findById catId webLogId = backgroundTask { + let findById (catId: CategoryId) webLogId = backgroundTask { use cmd = conn.CreateCommand () cmd.CommandText <- "SELECT * FROM category WHERE id = @id" - cmd.Parameters.AddWithValue ("@id", CategoryId.toString catId) |> ignore + cmd.Parameters.AddWithValue ("@id", catId.Value) |> ignore use! rdr = cmd.ExecuteReaderAsync () return Helpers.verifyWebLog webLogId (fun c -> c.WebLogId) Map.toCategory rdr } @@ -125,11 +125,11 @@ type SQLiteCategoryData (conn : SqliteConnection) = use cmd = conn.CreateCommand () // Reassign any children to the category's parent category cmd.CommandText <- "SELECT COUNT(id) FROM category WHERE parent_id = @parentId" - cmd.Parameters.AddWithValue ("@parentId", CategoryId.toString catId) |> ignore + cmd.Parameters.AddWithValue ("@parentId", catId.Value) |> ignore let! children = count cmd if children > 0 then cmd.CommandText <- "UPDATE category SET parent_id = @newParentId WHERE parent_id = @parentId" - cmd.Parameters.AddWithValue ("@newParentId", maybe (cat.ParentId |> Option.map CategoryId.toString)) + cmd.Parameters.AddWithValue ("@newParentId", maybe (cat.ParentId |> Option.map _.Value)) |> ignore do! write cmd // Delete the category off all posts where it is assigned, and the category itself @@ -139,7 +139,7 @@ type SQLiteCategoryData (conn : SqliteConnection) = AND post_id IN (SELECT id FROM post WHERE web_log_id = @webLogId); DELETE FROM category WHERE id = @id" cmd.Parameters.Clear () - let _ = cmd.Parameters.AddWithValue ("@id", CategoryId.toString catId) + let _ = cmd.Parameters.AddWithValue ("@id", catId.Value) addWebLogId cmd webLogId do! write cmd return if children = 0 then CategoryDeleted else ReassignedChildCategories diff --git a/src/MyWebLog.Data/SQLite/SQLitePostData.fs b/src/MyWebLog.Data/SQLite/SQLitePostData.fs index 257bdf7..d73cf86 100644 --- a/src/MyWebLog.Data/SQLite/SQLitePostData.fs +++ b/src/MyWebLog.Data/SQLite/SQLitePostData.fs @@ -83,7 +83,7 @@ type SQLitePostData (conn : SqliteConnection, ser : JsonSerializer) = /// Update a post's assigned categories let updatePostCategories postId oldCats newCats = backgroundTask { - let toDelete, toAdd = Utils.diffLists oldCats newCats CategoryId.toString + let toDelete, toAdd = Utils.diffLists oldCats newCats _.Value if List.isEmpty toDelete && List.isEmpty toAdd then return () else @@ -91,8 +91,8 @@ type SQLitePostData (conn : SqliteConnection, ser : JsonSerializer) = [ 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 + let runCmd (catId: CategoryId) = backgroundTask { + cmd.Parameters["@categoryId"].Value <- catId.Value do! write cmd } cmd.CommandText <- "DELETE FROM post_category WHERE post_id = @postId AND category_id = @categoryId" @@ -301,7 +301,7 @@ type SQLitePostData (conn : SqliteConnection, ser : JsonSerializer) = /// Get a page of categorized posts for the given web log (excludes revisions and prior permalinks) let findPageOfCategorizedPosts webLogId categoryIds pageNbr postsPerPage = backgroundTask { use cmd = conn.CreateCommand () - let catSql, catParams = inClause "AND pc.category_id" "catId" CategoryId.toString categoryIds + let catSql, catParams = inClause "AND pc.category_id" "catId" (_.Value) categoryIds cmd.CommandText <- $" {selectPost} INNER JOIN post_category pc ON pc.post_id = p.id diff --git a/src/MyWebLog.Data/SQLite/SQLiteWebLogUserData.fs b/src/MyWebLog.Data/SQLite/SQLiteWebLogUserData.fs index 8eb8cd9..f99bf05 100644 --- a/src/MyWebLog.Data/SQLite/SQLiteWebLogUserData.fs +++ b/src/MyWebLog.Data/SQLite/SQLiteWebLogUserData.fs @@ -19,7 +19,7 @@ type SQLiteWebLogUserData (conn : SqliteConnection) = cmd.Parameters.AddWithValue ("@preferredName", user.PreferredName) cmd.Parameters.AddWithValue ("@passwordHash", user.PasswordHash) cmd.Parameters.AddWithValue ("@url", maybe user.Url) - cmd.Parameters.AddWithValue ("@accessLevel", AccessLevel.toString user.AccessLevel) + cmd.Parameters.AddWithValue ("@accessLevel", user.AccessLevel.Value) cmd.Parameters.AddWithValue ("@createdOn", instantParam user.CreatedOn) cmd.Parameters.AddWithValue ("@lastSeenOn", maybeInstant user.LastSeenOn) ] |> ignore diff --git a/src/MyWebLog.Data/SQLiteData.fs b/src/MyWebLog.Data/SQLiteData.fs index d1a3aaf..61d5f48 100644 --- a/src/MyWebLog.Data/SQLiteData.fs +++ b/src/MyWebLog.Data/SQLiteData.fs @@ -188,7 +188,7 @@ type SQLiteData (conn : SqliteConnection, log : ILogger, ser : JsonS ImageUrl = Map.getString "image_url" podcastRdr |> Permalink AppleCategory = Map.getString "apple_category" podcastRdr AppleSubcategory = Map.tryString "apple_subcategory" podcastRdr - Explicit = Map.getString "explicit" podcastRdr |> ExplicitRating.parse + Explicit = Map.getString "explicit" podcastRdr |> ExplicitRating.Parse DefaultMediaType = Map.tryString "default_media_type" podcastRdr MediaBaseUrl = Map.tryString "media_base_url" podcastRdr PodcastGuid = Map.tryGuid "podcast_guid" podcastRdr @@ -220,7 +220,7 @@ type SQLiteData (conn : SqliteConnection, log : ILogger, ser : JsonS ImageUrl = Map.tryString "image_url" epRdr Subtitle = Map.tryString "subtitle" epRdr Explicit = Map.tryString "explicit" epRdr - |> Option.map ExplicitRating.parse + |> Option.map ExplicitRating.Parse Chapters = Map.tryString "chapters" epRdr |> Option.map (Utils.deserialize ser) ChapterFile = Map.tryString "chapter_file" epRdr diff --git a/src/MyWebLog.Data/Utils.fs b/src/MyWebLog.Data/Utils.fs index c241a65..14f94aa 100644 --- a/src/MyWebLog.Data/Utils.fs +++ b/src/MyWebLog.Data/Utils.fs @@ -12,7 +12,7 @@ let currentDbVersion = "v2.1" 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 + { Id = cat.Id.Value Slug = fullSlug Name = cat.Name Description = cat.Description @@ -24,7 +24,7 @@ let rec orderByHierarchy (cats : Category list) parentId slugBase parentNames = } /// 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 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 @@ -38,21 +38,21 @@ let diffPermalinks oldLinks newLinks = /// Find the revisions added and removed let diffRevisions oldRevs newRevs = - diffLists oldRevs newRevs (fun (rev : Revision) -> $"{rev.AsOf.ToUnixTimeTicks ()}|{MarkupText.toString rev.Text}") + diffLists oldRevs newRevs (fun (rev: Revision) -> $"{rev.AsOf.ToUnixTimeTicks()}|{MarkupText.toString rev.Text}") open MyWebLog.Converters open Newtonsoft.Json /// Serialize an object to JSON -let serialize<'T> ser (item : 'T) = - JsonConvert.SerializeObject (item, Json.settings ser) +let serialize<'T> ser (item: 'T) = + JsonConvert.SerializeObject(item, Json.settings ser) /// Deserialize a JSON string -let deserialize<'T> (ser : JsonSerializer) value = - JsonConvert.DeserializeObject<'T> (value, Json.settings ser) +let deserialize<'T> (ser: JsonSerializer) value = + JsonConvert.DeserializeObject<'T>(value, Json.settings ser) open Microsoft.Extensions.Logging /// Log a migration step -let logMigrationStep<'T> (log : ILogger<'T>) migration message = +let logMigrationStep<'T> (log: ILogger<'T>) migration message = log.LogInformation $"Migrating %s{migration}: %s{message}" diff --git a/src/MyWebLog.Domain/DataTypes.fs b/src/MyWebLog.Domain/DataTypes.fs index cae8b76..baf4742 100644 --- a/src/MyWebLog.Domain/DataTypes.fs +++ b/src/MyWebLog.Domain/DataTypes.fs @@ -31,7 +31,7 @@ module Category = /// An empty category let empty = { - Id = CategoryId.empty + Id = CategoryId.Empty WebLogId = WebLogId.empty Name = "" Slug = "" @@ -76,7 +76,7 @@ module Comment = /// An empty comment let empty = { - Id = CommentId.empty + Id = CommentId.Empty PostId = PostId.empty InReplyToId = None Name = "" @@ -485,4 +485,4 @@ module WebLogUser = /// Does a user have the required access level? let hasAccess level user = - AccessLevel.hasAccess level user.AccessLevel + user.AccessLevel.HasAccess level diff --git a/src/MyWebLog.Domain/SupportTypes.fs b/src/MyWebLog.Domain/SupportTypes.fs index c3e0fe1..e6ac59c 100644 --- a/src/MyWebLog.Domain/SupportTypes.fs +++ b/src/MyWebLog.Domain/SupportTypes.fs @@ -10,20 +10,20 @@ module private Helpers = /// Create a new ID (short GUID) // https://www.madskristensen.net/blog/A-shorter-and-URL-friendly-GUID let newId () = - Convert.ToBase64String(Guid.NewGuid().ToByteArray ()).Replace('/', '_').Replace('+', '-')[..22] + Convert.ToBase64String(Guid.NewGuid().ToByteArray()).Replace('/', '_').Replace('+', '-')[..22] /// Functions to support NodaTime manipulation module Noda = /// The clock to use when getting "now" (will make mutable for testing) - let clock : IClock = SystemClock.Instance + let clock: IClock = SystemClock.Instance /// The Unix epoch let epoch = Instant.FromUnixTimeSeconds 0L /// Truncate an instant to remove fractional seconds - let toSecondsPrecision (value : Instant) = + let toSecondsPrecision (value: Instant) = Instant.FromUnixTimeSeconds(value.ToUnixTimeSeconds()) /// The current Instant, with fractional seconds truncated @@ -31,11 +31,12 @@ module Noda = clock.GetCurrentInstant >> toSecondsPrecision /// Convert a date/time to an Instant with whole seconds - let fromDateTime (dt : DateTime) = + let fromDateTime (dt: DateTime) = Instant.FromDateTimeUtc(DateTime(dt.Ticks, DateTimeKind.Utc)) |> toSecondsPrecision /// A user's access level +[] type AccessLevel = /// The user may create and publish posts and edit the ones they have created | Author @@ -45,74 +46,73 @@ type AccessLevel = | WebLogAdmin /// The user may manage themes (which affects all web logs for an installation) | Administrator - -/// Functions to support access levels -module AccessLevel = - - /// Weightings for access levels - let private weights = - [ Author, 10 - Editor, 20 - WebLogAdmin, 30 - Administrator, 40 - ] - |> Map.ofList - - /// Convert an access level to its string representation - let toString = - function - | Author -> "Author" - | Editor -> "Editor" - | WebLogAdmin -> "WebLogAdmin" - | Administrator -> "Administrator" /// Parse an access level from its string representation - let parse it = - match it with - | "Author" -> Author - | "Editor" -> Editor - | "WebLogAdmin" -> WebLogAdmin + static member Parse = + function + | "Author" -> Author + | "Editor" -> Editor + | "WebLogAdmin" -> WebLogAdmin | "Administrator" -> Administrator - | _ -> invalidOp $"{it} is not a valid access level" + | it -> invalidArg "level" $"{it} is not a valid access level" + + /// The string representation of this access level + member this.Value = + match this with + | Author -> "Author" + | Editor -> "Editor" + | WebLogAdmin -> "WebLogAdmin" + | Administrator -> "Administrator" /// Does a given access level allow an action that requires a certain access level? - let hasAccess needed held = - weights[needed] <= weights[held] + member this.HasAccess(needed: AccessLevel) = + // TODO: Move this to user where it seems to belong better... + let weights = + [ Author, 10 + Editor, 20 + WebLogAdmin, 30 + Administrator, 40 + ] + |> Map.ofList + weights[needed] <= weights[this] /// An identifier for a category -type CategoryId = CategoryId of string - -/// Functions to support category IDs -module CategoryId = +[] +type CategoryId = + | CategoryId of string /// An empty category ID - let empty = CategoryId "" - - /// Convert a category ID to a string - let toString = function CategoryId ci -> ci + static member Empty = CategoryId "" /// Create a new category ID - let create = newId >> CategoryId + static member Create = + newId >> CategoryId + + /// The string representation of this category ID + member this.Value = + match this with CategoryId it -> it /// An identifier for a comment -type CommentId = CommentId of string - -/// Functions to support comment IDs -module CommentId = +[] +type CommentId = + | CommentId of string /// An empty comment ID - let empty = CommentId "" - - /// Convert a comment ID to a string - let toString = function CommentId ci -> ci + static member Empty = CommentId "" /// Create a new comment ID - let create = newId >> CommentId + static member Create = + newId >> CommentId + + /// The string representation of this comment ID + member this.Value = + match this with CommentId it -> it /// Statuses for post comments +[] type CommentStatus = /// The comment is approved | Approved @@ -121,77 +121,71 @@ type CommentStatus = /// 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 + static member Parse = + function | "Approved" -> Approved - | "Pending" -> Pending - | "Spam" -> Spam - | it -> invalidArg "status" $"{it} is not a valid comment status" + | "Pending" -> Pending + | "Spam" -> Spam + | it -> invalidArg "status" $"{it} is not a valid comment status" + + /// Convert a comment status to a string + member this.Value = + match this with Approved -> "Approved" | Pending -> "Pending" | Spam -> "Spam" /// 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 = + static member Parse = function - | "yes" -> Yes - | "no" -> No + | "yes" -> Yes + | "no" -> No | "clean" -> Clean - | x -> invalidArg "rating" $"{x} is not a valid explicit rating" + | it -> invalidArg "rating" $"{it} is not a valid explicit rating" + + /// The string value of this rating + member this.Value = + match this with Yes -> "yes" | No -> "no" | Clean -> "clean" /// A location (specified by Podcast Index) type Location = { /// The name of the location (free-form text) - Name : string + Name: string /// A geographic coordinate string (RFC 5870) - Geo : string option + Geo: string option /// An OpenStreetMap query - Osm : string option + Osm: string option } /// A chapter in a podcast episode type Chapter = { /// The start time for the chapter - StartTime : Duration + StartTime: Duration /// The title for this chapter - Title : string option + Title: string option /// A URL for an image for this chapter - ImageUrl : string option + ImageUrl: string option /// Whether this chapter is hidden - IsHidden : bool option + IsHidden: bool option /// The episode end time for the chapter - EndTime : Duration option + EndTime: Duration option /// A location that applies to a chapter - Location : Location option + Location: Location option } @@ -200,65 +194,62 @@ open NodaTime.Text /// A podcast episode type Episode = { /// The URL to the media file for the episode (may be permalink) - Media : string + Media: string /// The length of the media file, in bytes - Length : int64 + Length: int64 /// The duration of the episode - Duration : Duration option + Duration: Duration option /// The media type of the file (overrides podcast default if present) - MediaType : string option + MediaType: string option /// The URL to the image file for this episode (overrides podcast image if present, may be permalink) - ImageUrl : string option + ImageUrl: string option /// A subtitle for this episode - Subtitle : string option + Subtitle: string option /// This episode's explicit rating (overrides podcast rating if present) - Explicit : ExplicitRating option + Explicit: ExplicitRating option /// Chapters for this episode - Chapters : Chapter list option + Chapters: Chapter list option /// A link to a chapter file - ChapterFile : string option + ChapterFile: string option /// The MIME type for the chapter file - ChapterType : string option + ChapterType: string option /// The URL for the transcript of the episode (may be permalink) - TranscriptUrl : string option + TranscriptUrl: string option /// The MIME type of the transcript - TranscriptType : string option + TranscriptType: string option /// The language in which the transcript is written - TranscriptLang : string option + TranscriptLang: string option /// If true, the transcript will be declared (in the feed) to be a captions file - TranscriptCaptions : bool option + TranscriptCaptions: bool option /// The season number (for serialized podcasts) - SeasonNumber : int option + SeasonNumber: int option /// A description of the season - SeasonDescription : string option + SeasonDescription: string option /// The episode number - EpisodeNumber : double option + EpisodeNumber: double option /// A description of the episode - EpisodeDescription : string option -} - -/// Functions to support episodes -module Episode = + EpisodeDescription: string option +} with /// An empty episode - let empty = { + static member Empty = { Media = "" Length = 0L Duration = None @@ -280,8 +271,8 @@ module Episode = } /// Format a duration for an episode - let formatDuration ep = - ep.Duration |> Option.map (DurationPattern.CreateWithInvariantCulture("H:mm:ss").Format) + member this.FormatDuration() = + this.Duration |> Option.map (DurationPattern.CreateWithInvariantCulture("H:mm:ss").Format) open Markdig diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index a0ebb3f..28c85e5 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -305,7 +305,7 @@ module DisplayUser = LastName = user.LastName PreferredName = user.PreferredName Url = defaultArg user.Url "" - AccessLevel = AccessLevel.toString user.AccessLevel + AccessLevel = user.AccessLevel.Value CreatedOn = WebLog.localTime webLog user.CreatedOn LastSeenOn = user.LastSeenOn |> Option.map (WebLog.localTime webLog) |> Option.toNullable } @@ -332,11 +332,11 @@ type EditCategoryModel = /// Create an edit model from an existing category static member fromCategory (cat : Category) = - { CategoryId = CategoryId.toString cat.Id + { CategoryId = cat.Id.Value Name = cat.Name Slug = cat.Slug Description = defaultArg cat.Description "" - ParentId = cat.ParentId |> Option.map CategoryId.toString |> Option.defaultValue "" + ParentId = cat.ParentId |> Option.map _.Value |> Option.defaultValue "" } /// Is this a new category? @@ -457,7 +457,7 @@ type EditCustomFeedModel = ImageUrl = Permalink.toString p.ImageUrl AppleCategory = p.AppleCategory AppleSubcategory = defaultArg p.AppleSubcategory "" - Explicit = ExplicitRating.toString p.Explicit + Explicit = p.Explicit.Value DefaultMediaType = defaultArg p.DefaultMediaType "" MediaBaseUrl = defaultArg p.MediaBaseUrl "" FundingUrl = defaultArg p.FundingUrl "" @@ -486,7 +486,7 @@ type EditCustomFeedModel = ImageUrl = Permalink this.ImageUrl AppleCategory = this.AppleCategory AppleSubcategory = noneIfBlank this.AppleSubcategory - Explicit = ExplicitRating.parse this.Explicit + Explicit = ExplicitRating.Parse this.Explicit DefaultMediaType = noneIfBlank this.DefaultMediaType MediaBaseUrl = noneIfBlank this.MediaBaseUrl PodcastGuid = noneIfBlank this.PodcastGuid |> Option.map Guid.Parse @@ -714,11 +714,11 @@ type EditPostModel = /// 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 + match post.Revisions |> List.sortByDescending (_.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 - let episode = defaultArg post.Episode Episode.empty + let episode = defaultArg post.Episode Episode.Empty { PostId = PostId.toString post.Id Title = post.Title Permalink = Permalink.toString post.Permalink @@ -726,22 +726,22 @@ type EditPostModel = Text = MarkupText.text latest.Text Tags = String.Join (", ", post.Tags) Template = defaultArg post.Template "" - CategoryIds = post.CategoryIds |> List.map CategoryId.toString |> Array.ofList + CategoryIds = post.CategoryIds |> List.map (_.Value) |> 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 + MetaNames = post.Metadata |> List.map (_.Name) |> Array.ofList + MetaValues = post.Metadata |> List.map (_.Value) |> Array.ofList SetPublished = false PubOverride = post.PublishedOn |> Option.map (WebLog.localTime webLog) |> Option.toNullable SetUpdated = false IsEpisode = Option.isSome post.Episode Media = episode.Media Length = episode.Length - Duration = defaultArg (Episode.formatDuration episode) "" + Duration = defaultArg (episode.FormatDuration()) "" MediaType = defaultArg episode.MediaType "" ImageUrl = defaultArg episode.ImageUrl "" Subtitle = defaultArg episode.Subtitle "" - Explicit = defaultArg (episode.Explicit |> Option.map ExplicitRating.toString) "" + Explicit = defaultArg (episode.Explicit |> Option.map (_.Value)) "" ChapterFile = defaultArg episode.ChapterFile "" ChapterType = defaultArg episode.ChapterType "" TranscriptUrl = defaultArg episode.TranscriptUrl "" @@ -800,7 +800,7 @@ type EditPostModel = MediaType = noneIfBlank this.MediaType ImageUrl = noneIfBlank this.ImageUrl Subtitle = noneIfBlank this.Subtitle - Explicit = noneIfBlank this.Explicit |> Option.map ExplicitRating.parse + Explicit = noneIfBlank this.Explicit |> Option.map ExplicitRating.Parse Chapters = match post.Episode with Some e -> e.Chapters | None -> None ChapterFile = noneIfBlank this.ChapterFile ChapterType = noneIfBlank this.ChapterType @@ -960,7 +960,7 @@ type EditUserModel = /// Construct a displayed user from a web log user static member fromUser (user : WebLogUser) = { Id = WebLogUserId.toString user.Id - AccessLevel = AccessLevel.toString user.AccessLevel + AccessLevel = user.AccessLevel.Value Url = defaultArg user.Url "" Email = user.Email FirstName = user.FirstName @@ -974,9 +974,9 @@ type EditUserModel = member this.IsNew = this.Id = "new" /// Update a user with values from this model (excludes password) - member this.UpdateUser (user : WebLogUser) = + member this.UpdateUser (user: WebLogUser) = { user with - AccessLevel = AccessLevel.parse this.AccessLevel + AccessLevel = AccessLevel.Parse this.AccessLevel Email = this.Email Url = noneIfBlank this.Url FirstName = this.FirstName @@ -1126,7 +1126,7 @@ type PostListItem = PublishedOn = post.PublishedOn |> Option.map inTZ |> Option.toNullable UpdatedOn = inTZ post.UpdatedOn Text = addBaseToRelativeUrls extra post.Text - CategoryIds = post.CategoryIds |> List.map CategoryId.toString + CategoryIds = post.CategoryIds |> List.map _.Value Tags = post.Tags Episode = post.Episode Metadata = post.Metadata diff --git a/src/MyWebLog/Caches.fs b/src/MyWebLog/Caches.fs index cfb0e0f..05bda8f 100644 --- a/src/MyWebLog/Caches.fs +++ b/src/MyWebLog/Caches.fs @@ -42,7 +42,7 @@ module Extensions = member this.UserAccessLevel = this.User.Claims |> Seq.tryFind (fun claim -> claim.Type = ClaimTypes.Role) - |> Option.map (fun claim -> AccessLevel.parse claim.Value) + |> Option.map (fun claim -> AccessLevel.Parse claim.Value) /// The user ID for the current request member this.UserId = @@ -53,7 +53,7 @@ module Extensions = /// Does the current user have the requested level of access? member this.HasAccessLevel level = - defaultArg (this.UserAccessLevel |> Option.map (AccessLevel.hasAccess level)) false + defaultArg (this.UserAccessLevel |> Option.map (fun it -> it.HasAccess level)) false open System.Collections.Concurrent diff --git a/src/MyWebLog/Handlers/Admin.fs b/src/MyWebLog/Handlers/Admin.fs index 4025843..de59270 100644 --- a/src/MyWebLog/Handlers/Admin.fs +++ b/src/MyWebLog/Handlers/Admin.fs @@ -177,7 +177,7 @@ module Category = let data = ctx.Data let! model = ctx.BindFormAsync () let category = - if model.IsNew then someTask { Category.empty with Id = CategoryId.create (); WebLogId = ctx.WebLog.Id } + if model.IsNew then someTask { Category.empty with Id = CategoryId.Create(); WebLogId = ctx.WebLog.Id } else data.Category.FindById (CategoryId model.CategoryId) ctx.WebLog.Id match! category with | Some cat -> diff --git a/src/MyWebLog/Handlers/Feed.fs b/src/MyWebLog/Handlers/Feed.fs index 7db1dd9..2db2de4 100644 --- a/src/MyWebLog/Handlers/Feed.fs +++ b/src/MyWebLog/Handlers/Feed.fs @@ -48,8 +48,8 @@ let deriveFeedType (ctx : HttpContext) feedPath : (FeedType * int) option = /// 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) + let childIds (catId: CategoryId) = + let cat = CategoryCache.get ctx |> Array.find (fun c -> c.Id = catId.Value) getCategoryIds cat.Slug ctx let data = ctx.Data match feedType with @@ -116,7 +116,7 @@ let private toFeedItem webLog (authors : MetaItem list) (cats : DisplayCategory[ 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) + let cat = cats |> Array.find (fun c -> c.Id = catId.Value) SyndicationCategory (cat.Name, WebLog.absoluteUrl webLog (Permalink $"category/{cat.Slug}/"), cat.Name)) post.Tags |> List.map (fun tag -> @@ -143,28 +143,27 @@ let private addEpisode webLog (podcast : PodcastOptions) (episode : Episode) (po | link -> WebLog.absoluteUrl webLog (Permalink link) let epMediaType = [ episode.MediaType; podcast.DefaultMediaType ] |> List.tryFind Option.isSome |> Option.flatten let epImageUrl = defaultArg episode.ImageUrl (Permalink.toString podcast.ImageUrl) |> toAbsolute webLog - let epExplicit = defaultArg episode.Explicit podcast.Explicit |> ExplicitRating.toString + let epExplicit = (defaultArg episode.Explicit podcast.Explicit).Value - let xmlDoc = XmlDocument () + let xmlDoc = XmlDocument() let enclosure = let it = xmlDoc.CreateElement "enclosure" - it.SetAttribute ("url", epMediaUrl) - it.SetAttribute ("length", string episode.Length) - epMediaType |> Option.iter (fun typ -> it.SetAttribute ("type", typ)) + it.SetAttribute("url", epMediaUrl) + it.SetAttribute("length", string episode.Length) + epMediaType |> Option.iter (fun typ -> it.SetAttribute("type", typ)) it let image = - let it = xmlDoc.CreateElement ("itunes", "image", Namespace.iTunes) - it.SetAttribute ("href", epImageUrl) + 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) - episode.Subtitle |> Option.iter (fun it -> item.ElementExtensions.Add ("subtitle", Namespace.iTunes, it)) - Episode.formatDuration episode - |> Option.iter (fun it -> item.ElementExtensions.Add ("duration", Namespace.iTunes, it)) + item.ElementExtensions.Add("creator", Namespace.dc, podcast.DisplayedAuthor) + item.ElementExtensions.Add("author", Namespace.iTunes, podcast.DisplayedAuthor) + item.ElementExtensions.Add("explicit", Namespace.iTunes, epExplicit) + episode.Subtitle |> Option.iter (fun it -> item.ElementExtensions.Add("subtitle", Namespace.iTunes, it)) + episode.FormatDuration() |> Option.iter (fun it -> item.ElementExtensions.Add("duration", Namespace.iTunes, it)) match episode.ChapterFile with | Some chapters -> @@ -174,21 +173,20 @@ let private addEpisode webLog (podcast : PodcastOptions) (episode : Episode) (po | Some mime -> Some mime | None when chapters.EndsWith ".json" -> Some "application/json+chapters" | None -> None - let elt = xmlDoc.CreateElement ("podcast", "chapters", Namespace.podcast) - elt.SetAttribute ("url", url) - typ |> Option.iter (fun it -> elt.SetAttribute ("type", it)) + let elt = xmlDoc.CreateElement("podcast", "chapters", Namespace.podcast) + elt.SetAttribute("url", url) + typ |> Option.iter (fun it -> elt.SetAttribute("type", it)) item.ElementExtensions.Add elt | None -> () match episode.TranscriptUrl with | Some transcript -> let url = toAbsolute webLog transcript - let elt = xmlDoc.CreateElement ("podcast", "transcript", Namespace.podcast) - elt.SetAttribute ("url", url) - elt.SetAttribute ("type", Option.get episode.TranscriptType) - episode.TranscriptLang |> Option.iter (fun it -> elt.SetAttribute ("language", it)) - if defaultArg episode.TranscriptCaptions false then - elt.SetAttribute ("rel", "captions") + let elt = xmlDoc.CreateElement("podcast", "transcript", Namespace.podcast) + elt.SetAttribute("url", url) + elt.SetAttribute("type", Option.get episode.TranscriptType) + episode.TranscriptLang |> Option.iter (fun it -> elt.SetAttribute("language", it)) + if defaultArg episode.TranscriptCaptions false then elt.SetAttribute("rel", "captions") item.ElementExtensions.Add elt | None -> () @@ -196,38 +194,37 @@ let private addEpisode webLog (podcast : PodcastOptions) (episode : Episode) (po | Some season -> match episode.SeasonDescription with | Some desc -> - let elt = xmlDoc.CreateElement ("podcast", "season", Namespace.podcast) - elt.SetAttribute ("name", desc) + let elt = xmlDoc.CreateElement("podcast", "season", Namespace.podcast) + elt.SetAttribute("name", desc) elt.InnerText <- string season item.ElementExtensions.Add elt - | None -> item.ElementExtensions.Add ("season", Namespace.podcast, string season) + | None -> item.ElementExtensions.Add("season", Namespace.podcast, string season) | None -> () match episode.EpisodeNumber with | Some epNumber -> match episode.EpisodeDescription with | Some desc -> - let elt = xmlDoc.CreateElement ("podcast", "episode", Namespace.podcast) - elt.SetAttribute ("name", desc) + let elt = xmlDoc.CreateElement("podcast", "episode", Namespace.podcast) + elt.SetAttribute("name", desc) elt.InnerText <- string epNumber item.ElementExtensions.Add elt - | None -> item.ElementExtensions.Add ("episode", Namespace.podcast, string epNumber) + | None -> item.ElementExtensions.Add("episode", Namespace.podcast, string epNumber) | None -> () 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") + 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.map (fun it -> TimeSpan.Parse(it.Value.Split(" ")[0]), it.Value[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) + 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 @@ -300,21 +297,21 @@ let private addPodcast webLog (rssFeed : SyndicationFeed) (feed : CustomFeed) = 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) + rssFeed.ElementExtensions.Add("summary", Namespace.iTunes, podcast.Summary) + rssFeed.ElementExtensions.Add("author", Namespace.iTunes, podcast.DisplayedAuthor) + rssFeed.ElementExtensions.Add("explicit", Namespace.iTunes, podcast.Explicit.Value) podcast.Subtitle |> Option.iter (fun sub -> rssFeed.ElementExtensions.Add ("subtitle", Namespace.iTunes, sub)) podcast.FundingUrl |> Option.iter (fun url -> - let funding = xmlDoc.CreateElement ("podcast", "funding", Namespace.podcast) - funding.SetAttribute ("url", toAbsolute webLog url) + let funding = xmlDoc.CreateElement("podcast", "funding", Namespace.podcast) + funding.SetAttribute("url", toAbsolute webLog url) funding.InnerText <- defaultArg podcast.FundingText "Support This Podcast" rssFeed.ElementExtensions.Add funding) podcast.PodcastGuid |> Option.iter (fun guid -> - rssFeed.ElementExtensions.Add ("guid", Namespace.podcast, guid.ToString().ToLowerInvariant ())) + rssFeed.ElementExtensions.Add("guid", Namespace.podcast, guid.ToString().ToLowerInvariant())) podcast.Medium - |> Option.iter (fun med -> rssFeed.ElementExtensions.Add ("medium", Namespace.podcast, PodcastMedium.toString med)) + |> Option.iter (fun med -> rssFeed.ElementExtensions.Add("medium", Namespace.podcast, PodcastMedium.toString med)) /// Get the feed's self reference and non-feed link let private selfAndLink webLog feedType ctx = diff --git a/src/MyWebLog/Handlers/Helpers.fs b/src/MyWebLog/Handlers/Helpers.fs index 2edefe8..b1f4bd3 100644 --- a/src/MyWebLog/Handlers/Helpers.fs +++ b/src/MyWebLog/Handlers/Helpers.fs @@ -348,12 +348,12 @@ let requireUser : HttpHandler = requiresAuthentication Error.notAuthorized /// Require a specific level of access for a route let requireAccess level : HttpHandler = fun next ctx -> task { match ctx.UserAccessLevel with - | Some userLevel when AccessLevel.hasAccess level userLevel -> return! next ctx + | Some userLevel when userLevel.HasAccess level -> return! next ctx | Some userLevel -> do! addMessage ctx { UserMessage.warning with - Message = $"The page you tried to access requires {AccessLevel.toString level} privileges" - Detail = Some $"Your account only has {AccessLevel.toString userLevel} privileges" + Message = $"The page you tried to access requires {level.Value} privileges" + Detail = Some $"Your account only has {userLevel.Value} privileges" } return! Error.notAuthorized next ctx | None -> diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index 087f66c..0f5ea97 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -242,10 +242,10 @@ let edit postId : HttpHandler = requireAccess Author >=> fun next ctx -> task { |> Array.mapi (fun idx (name, value) -> [| string idx; name; value |])) |> addToHash "templates" templates |> addToHash "explicit_values" [| - KeyValuePair.Create ("", "– Default –") - KeyValuePair.Create (ExplicitRating.toString Yes, "Yes") - KeyValuePair.Create (ExplicitRating.toString No, "No") - KeyValuePair.Create (ExplicitRating.toString Clean, "Clean") + KeyValuePair.Create("", "– Default –") + KeyValuePair.Create(Yes.Value, "Yes") + KeyValuePair.Create(No.Value, "No") + KeyValuePair.Create(Clean.Value, "Clean") |] |> adminView "post-edit" next ctx | Some _ -> return! Error.notAuthorized next ctx diff --git a/src/MyWebLog/Handlers/User.fs b/src/MyWebLog/Handlers/User.fs index 43d9ccc..389fe56 100644 --- a/src/MyWebLog/Handlers/User.fs +++ b/src/MyWebLog/Handlers/User.fs @@ -58,7 +58,7 @@ let doLogOn : HttpHandler = fun next ctx -> task { Claim (ClaimTypes.NameIdentifier, WebLogUserId.toString user.Id) Claim (ClaimTypes.Name, $"{user.FirstName} {user.LastName}") Claim (ClaimTypes.GivenName, user.PreferredName) - Claim (ClaimTypes.Role, AccessLevel.toString user.AccessLevel) + Claim (ClaimTypes.Role, user.AccessLevel.Value) } let identity = ClaimsIdentity (claims, CookieAuthenticationDefaults.AuthenticationScheme) @@ -110,11 +110,10 @@ let private showEdit (model : EditUserModel) : HttpHandler = fun next ctx -> |> withAntiCsrf ctx |> addToHash ViewContext.Model model |> addToHash "access_levels" [| - KeyValuePair.Create (AccessLevel.toString Author, "Author") - KeyValuePair.Create (AccessLevel.toString Editor, "Editor") - KeyValuePair.Create (AccessLevel.toString WebLogAdmin, "Web Log Admin") - if ctx.HasAccessLevel Administrator then - KeyValuePair.Create (AccessLevel.toString Administrator, "Administrator") + KeyValuePair.Create(Author.Value, "Author") + KeyValuePair.Create(Editor.Value, "Editor") + KeyValuePair.Create(WebLogAdmin.Value, "Web Log Admin") + if ctx.HasAccessLevel Administrator then KeyValuePair.Create(Administrator.Value, "Administrator") |] |> adminBareView "user-edit" next ctx @@ -160,7 +159,7 @@ let private showMyInfo (model : EditMyInfoModel) (user : WebLogUser) : HttpHandl hashForPage "Edit Your Information" |> withAntiCsrf ctx |> addToHash ViewContext.Model model - |> addToHash "access_level" (AccessLevel.toString user.AccessLevel) + |> addToHash "access_level" (user.AccessLevel.Value) |> addToHash "created_on" (WebLog.localTime ctx.WebLog user.CreatedOn) |> addToHash "last_seen_on" (WebLog.localTime ctx.WebLog (defaultArg user.LastSeenOn (Instant.FromUnixTimeSeconds 0))) diff --git a/src/MyWebLog/Maintenance.fs b/src/MyWebLog/Maintenance.fs index 8413504..ee7d934 100644 --- a/src/MyWebLog/Maintenance.fs +++ b/src/MyWebLog/Maintenance.fs @@ -334,7 +334,7 @@ module Backup = | Some _ -> // Err'body gets new IDs... let newWebLogId = WebLogId.create () - let newCatIds = archive.Categories |> List.map (fun cat -> cat.Id, CategoryId.create ()) |> dict + 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 @@ -481,7 +481,7 @@ let private doUserUpgrade urlBase email (data : IData) = task { | WebLogAdmin -> do! data.WebLogUser.Update { user with AccessLevel = Administrator } printfn $"{email} is now an Administrator user" - | other -> eprintfn $"ERROR: {email} is an {AccessLevel.toString other}, not a WebLogAdmin" + | other -> eprintfn $"ERROR: {email} is an {other.Value}, not a WebLogAdmin" | None -> eprintfn $"ERROR: no user {email} found at {urlBase}" | None -> eprintfn $"ERROR: no web log found for {urlBase}" } -- 2.45.1 From 5fe2077974a9c2a7368e074e8edcf871c5c33c8a Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Fri, 15 Dec 2023 22:46:12 -0500 Subject: [PATCH 015/123] WIP on module/member conversion --- src/MyWebLog.Data/Converters.fs | 54 +- .../Postgres/PostgresCategoryData.fs | 13 +- src/MyWebLog.Data/Postgres/PostgresHelpers.fs | 4 +- .../Postgres/PostgresPageData.fs | 38 +- .../Postgres/PostgresPostData.fs | 52 +- .../Postgres/PostgresUploadData.fs | 2 +- src/MyWebLog.Data/RethinkDbData.fs | 2 +- src/MyWebLog.Data/SQLite/Helpers.fs | 4 +- src/MyWebLog.Data/SQLite/SQLitePageData.fs | 40 +- src/MyWebLog.Data/SQLite/SQLitePostData.fs | 72 +-- src/MyWebLog.Data/SQLite/SQLiteUploadData.fs | 4 +- src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs | 2 +- src/MyWebLog.Data/SQLiteData.fs | 4 +- src/MyWebLog.Data/Utils.fs | 4 +- src/MyWebLog.Domain/DataTypes.fs | 22 +- src/MyWebLog.Domain/SupportTypes.fs | 192 ++++--- src/MyWebLog.Domain/ViewModels.fs | 494 +++++++++--------- src/MyWebLog/DotLiquidBespoke.fs | 18 +- src/MyWebLog/Handlers/Admin.fs | 12 +- src/MyWebLog/Handlers/Feed.fs | 30 +- src/MyWebLog/Handlers/Page.fs | 8 +- src/MyWebLog/Handlers/Post.fs | 18 +- src/MyWebLog/Handlers/Routes.fs | 10 +- src/MyWebLog/Maintenance.fs | 14 +- 24 files changed, 548 insertions(+), 565 deletions(-) diff --git a/src/MyWebLog.Data/Converters.fs b/src/MyWebLog.Data/Converters.fs index 0c3be03..d68025d 100644 --- a/src/MyWebLog.Data/Converters.fs +++ b/src/MyWebLog.Data/Converters.fs @@ -51,39 +51,39 @@ module Json = 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 MarkupTextConverter() = + inherit JsonConverter() + override _.WriteJson(writer: JsonWriter, value: MarkupText, _: JsonSerializer) = + writer.WriteValue value.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) = + type PermalinkConverter() = + inherit JsonConverter() + override _.WriteJson(writer: JsonWriter, value: Permalink, _: JsonSerializer) = + writer.WriteValue value.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) = + type PageIdConverter() = + inherit JsonConverter() + override _.WriteJson(writer: JsonWriter, value: PageId, _: JsonSerializer) = + writer.WriteValue value.Value + override _.ReadJson(reader: JsonReader, _: Type, _: PageId, _: bool, _: JsonSerializer) = (string >> PageId) reader.Value - type PodcastMediumConverter () = - inherit JsonConverter () - override _.WriteJson (writer : JsonWriter, value : PodcastMedium, _ : JsonSerializer) = - writer.WriteValue (PodcastMedium.toString value) - override _.ReadJson (reader : JsonReader, _ : Type, _ : PodcastMedium, _ : bool, _ : JsonSerializer) = - (string >> PodcastMedium.parse) reader.Value + type PodcastMediumConverter() = + inherit JsonConverter() + override _.WriteJson(writer: JsonWriter, value: PodcastMedium, _: JsonSerializer) = + writer.WriteValue value.Value + override _.ReadJson(reader: JsonReader, _: Type, _: PodcastMedium, _: bool, _: JsonSerializer) = + (string >> PodcastMedium.Parse) 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) = + type PostIdConverter() = + inherit JsonConverter() + override _.WriteJson(writer: JsonWriter, value: PostId, _: JsonSerializer) = + writer.WriteValue value.Value + override _.ReadJson(reader: JsonReader, _: Type, _: PostId, _: bool, _: JsonSerializer) = (string >> PostId) reader.Value type TagMapIdConverter () = diff --git a/src/MyWebLog.Data/Postgres/PostgresCategoryData.fs b/src/MyWebLog.Data/Postgres/PostgresCategoryData.fs index 60ef682..08d041c 100644 --- a/src/MyWebLog.Data/Postgres/PostgresCategoryData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresCategoryData.fs @@ -7,7 +7,7 @@ open MyWebLog.Data open Npgsql.FSharp /// PostgreSQL myWebLog category data implementation -type PostgresCategoryData (log : ILogger) = +type PostgresCategoryData(log: ILogger) = /// Count all categories for the given web log let countAll webLogId = @@ -33,7 +33,7 @@ type PostgresCategoryData (log : ILogger) = let catIdSql, catIdParams = ordered |> Seq.filter (fun cat -> cat.ParentNames |> Array.contains it.Name) - |> Seq.map (fun cat -> cat.Id) + |> Seq.map _.Id |> Seq.append (Seq.singleton it.Id) |> List.ofSeq |> arrayContains (nameof Post.empty.CategoryIds) id @@ -43,10 +43,9 @@ type PostgresCategoryData (log : ILogger) = FROM {Table.Post} WHERE {Query.whereDataContains "@criteria"} AND {catIdSql}""" - [ "@criteria", - Query.jsonbDocParam {| webLogDoc webLogId with Status = PostStatus.toString Published |} - catIdParams - ] Map.toCount + [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = Published.Value |} + catIdParams ] + Map.toCount |> Async.AwaitTask |> Async.RunSynchronously it.Id, postCount) @@ -107,7 +106,7 @@ type PostgresCategoryData (log : ILogger) = |> Sql.executeTransactionAsync [ Query.Update.partialById Table.Post, posts |> List.map (fun post -> [ - "@id", Sql.string (PostId.toString post.Id) + "@id", Sql.string post.Id.Value "@data", Query.jsonbDocParam {| CategoryIds = post.CategoryIds |> List.filter (fun cat -> cat <> catId) |} ]) diff --git a/src/MyWebLog.Data/Postgres/PostgresHelpers.fs b/src/MyWebLog.Data/Postgres/PostgresHelpers.fs index 765e669..b51b1e6 100644 --- a/src/MyWebLog.Data/Postgres/PostgresHelpers.fs +++ b/src/MyWebLog.Data/Postgres/PostgresHelpers.fs @@ -144,7 +144,7 @@ module Map = /// Create a revision from the current row let toRevision (row : RowReader) : Revision = { AsOf = row.fieldValue "as_of" - Text = row.string "revision_text" |> MarkupText.parse + Text = row.string "revision_text" |> MarkupText.Parse } /// Create a theme asset from the current row @@ -206,7 +206,7 @@ module Revisions = let revParams<'TKey> (key : 'TKey) (keyFunc : 'TKey -> string) rev = [ typedParam "asOf" rev.AsOf "@id", Sql.string (keyFunc key) - "@text", Sql.string (MarkupText.toString rev.Text) + "@text", Sql.string rev.Text.Value ] /// The SQL statement to insert a revision diff --git a/src/MyWebLog.Data/Postgres/PostgresPageData.fs b/src/MyWebLog.Data/Postgres/PostgresPageData.fs index 6feb078..8be3d1b 100644 --- a/src/MyWebLog.Data/Postgres/PostgresPageData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresPageData.fs @@ -7,30 +7,30 @@ open MyWebLog.Data open Npgsql.FSharp /// PostgreSQL myWebLog page data implementation -type PostgresPageData (log : ILogger) = +type PostgresPageData (log: ILogger) = // SUPPORT FUNCTIONS /// Append revisions to a page - let appendPageRevisions (page : Page) = backgroundTask { + let appendPageRevisions (page: Page) = backgroundTask { log.LogTrace "Page.appendPageRevisions" - let! revisions = Revisions.findByEntityId Table.PageRevision Table.Page page.Id PageId.toString + let! revisions = Revisions.findByEntityId Table.PageRevision Table.Page page.Id _.Value return { page with Revisions = revisions } } /// Return a page with no text or revisions - let pageWithoutText (row : RowReader) = + let pageWithoutText (row: RowReader) = { fromData row with Text = "" } /// Update a page's revisions - let updatePageRevisions pageId oldRevs newRevs = + let updatePageRevisions (pageId: PageId) oldRevs newRevs = log.LogTrace "Page.updatePageRevisions" - Revisions.update Table.PageRevision Table.Page pageId PageId.toString oldRevs newRevs + Revisions.update Table.PageRevision Table.Page pageId (_.Value) oldRevs newRevs /// Does the given page exist? - let pageExists pageId webLogId = + let pageExists (pageId: PageId) webLogId = log.LogTrace "Page.pageExists" - Document.existsByWebLog Table.Page pageId PageId.toString webLogId + Document.existsByWebLog Table.Page pageId (_.Value) webLogId // IMPLEMENTATION FUNCTIONS @@ -51,9 +51,9 @@ type PostgresPageData (log : ILogger) = Count.byContains Table.Page {| webLogDoc webLogId with IsInPageList = true |} /// Find a page by its ID (without revisions) - let findById pageId webLogId = + let findById (pageId: PageId) webLogId = log.LogTrace "Page.findById" - Document.findByIdAndWebLog Table.Page pageId PageId.toString webLogId + Document.findByIdAndWebLog Table.Page pageId (_.Value) webLogId /// Find a complete page by its ID let findFullById pageId webLogId = backgroundTask { @@ -70,15 +70,15 @@ type PostgresPageData (log : ILogger) = log.LogTrace "Page.delete" match! pageExists pageId webLogId with | true -> - do! Delete.byId Table.Page (PageId.toString pageId) + do! Delete.byId Table.Page pageId.Value return true | false -> return false } /// Find a page by its permalink for the given web log - let findByPermalink permalink webLogId = + let findByPermalink (permalink: Permalink) webLogId = log.LogTrace "Page.findByPermalink" - Find.byContains Table.Page {| webLogDoc webLogId with Permalink = Permalink.toString permalink |} + Find.byContains Table.Page {| webLogDoc webLogId with Permalink = permalink.Value |} |> tryHead /// Find the current permalink within a set of potential prior permalinks for the given web log @@ -87,7 +87,7 @@ type PostgresPageData (log : ILogger) = if List.isEmpty permalinks then return None else let linkSql, linkParam = - arrayContains (nameof Page.empty.PriorPermalinks) Permalink.toString permalinks + arrayContains (nameof Page.empty.PriorPermalinks) (fun (it: Permalink) -> it.Value) permalinks return! Custom.single $"""SELECT data ->> '{nameof Page.empty.Permalink}' AS permalink @@ -125,7 +125,7 @@ type PostgresPageData (log : ILogger) = fromData /// Restore pages from a backup - let restore (pages : Page list) = backgroundTask { + let restore (pages: Page list) = backgroundTask { log.LogTrace "Page.restore" let revisions = pages |> List.collect (fun p -> p.Revisions |> List.map (fun r -> p.Id, r)) let! _ = @@ -134,15 +134,15 @@ type PostgresPageData (log : ILogger) = |> Sql.executeTransactionAsync [ Query.insert Table.Page, pages - |> List.map (fun page -> Query.docParameters (PageId.toString page.Id) { page with Revisions = [] }) + |> List.map (fun page -> Query.docParameters page.Id.Value { page with Revisions = [] }) Revisions.insertSql Table.PageRevision, - revisions |> List.map (fun (pageId, rev) -> Revisions.revParams pageId PageId.toString rev) + revisions |> List.map (fun (pageId, rev) -> Revisions.revParams pageId (_.Value) rev) ] () } /// Save a page - let save (page : Page) = backgroundTask { + let save (page: Page) = backgroundTask { log.LogTrace "Page.save" let! oldPage = findFullById page.Id page.WebLogId do! save Table.Page { page with Revisions = [] } @@ -155,7 +155,7 @@ type PostgresPageData (log : ILogger) = log.LogTrace "Page.updatePriorPermalinks" match! pageExists pageId webLogId with | true -> - do! Update.partialById Table.Page (PageId.toString pageId) {| PriorPermalinks = permalinks |} + do! Update.partialById Table.Page pageId.Value {| PriorPermalinks = permalinks |} return true | false -> return false } diff --git a/src/MyWebLog.Data/Postgres/PostgresPostData.fs b/src/MyWebLog.Data/Postgres/PostgresPostData.fs index 70a6c54..c1f6248 100644 --- a/src/MyWebLog.Data/Postgres/PostgresPostData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresPostData.fs @@ -7,15 +7,15 @@ open MyWebLog.Data open NodaTime.Text open Npgsql.FSharp -/// PostgreSQL myWebLog post data implementation -type PostgresPostData (log : ILogger) = +/// PostgreSQL myWebLog post data implementation +type PostgresPostData(log: ILogger) = // SUPPORT FUNCTIONS /// Append revisions to a post - let appendPostRevisions (post : Post) = backgroundTask { + let appendPostRevisions (post: Post) = backgroundTask { log.LogTrace "Post.appendPostRevisions" - let! revisions = Revisions.findByEntityId Table.PostRevision Table.Post post.Id PostId.toString + let! revisions = Revisions.findByEntityId Table.PostRevision Table.Post post.Id _.Value return { post with Revisions = revisions } } @@ -24,34 +24,33 @@ type PostgresPostData (log : ILogger) = { fromData row with Text = "" } /// Update a post's revisions - let updatePostRevisions postId oldRevs newRevs = + let updatePostRevisions (postId: PostId) oldRevs newRevs = log.LogTrace "Post.updatePostRevisions" - Revisions.update Table.PostRevision Table.Post postId PostId.toString oldRevs newRevs + Revisions.update Table.PostRevision Table.Post postId (_.Value) oldRevs newRevs /// Does the given post exist? - let postExists postId webLogId = + let postExists (postId: PostId) webLogId = log.LogTrace "Post.postExists" - Document.existsByWebLog Table.Post postId PostId.toString webLogId + Document.existsByWebLog Table.Post postId (_.Value) webLogId // IMPLEMENTATION FUNCTIONS /// Count posts in a status for the given web log - let countByStatus status webLogId = + let countByStatus (status: PostStatus) webLogId = log.LogTrace "Post.countByStatus" - Count.byContains Table.Post {| webLogDoc webLogId with Status = PostStatus.toString status |} + Count.byContains Table.Post {| webLogDoc webLogId with Status = status.Value |} /// Find a post by its ID for the given web log (excluding revisions) let findById postId webLogId = log.LogTrace "Post.findById" - Document.findByIdAndWebLog Table.Post postId PostId.toString webLogId + Document.findByIdAndWebLog Table.Post postId (_.Value) webLogId /// Find a post by its permalink for the given web log (excluding revisions and prior permalinks) - let findByPermalink permalink webLogId = + let findByPermalink (permalink: Permalink) webLogId = log.LogTrace "Post.findByPermalink" Custom.single (selectWithCriteria Table.Post) - [ "@criteria", - Query.jsonbDocParam {| webLogDoc webLogId with Permalink = Permalink.toString permalink |} - ] fromData + [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Permalink = permalink.Value |} ] + fromData /// Find a complete post by its ID for the given web log let findFullById postId webLogId = backgroundTask { @@ -68,11 +67,10 @@ type PostgresPostData (log : ILogger) = log.LogTrace "Post.delete" match! postExists postId webLogId with | true -> - let theId = PostId.toString postId do! Custom.nonQuery $"""DELETE FROM {Table.PostComment} WHERE {Query.whereDataContains "@criteria"}; DELETE FROM {Table.Post} WHERE id = @id""" - [ "@id", Sql.string theId; "@criteria", Query.jsonbDocParam {| PostId = theId |} ] + [ "@id", Sql.string postId.Value; "@criteria", Query.jsonbDocParam {| PostId = postId.Value |} ] return true | false -> return false } @@ -83,7 +81,7 @@ type PostgresPostData (log : ILogger) = if List.isEmpty permalinks then return None else let linkSql, linkParam = - arrayContains (nameof Post.empty.PriorPermalinks) Permalink.toString permalinks + arrayContains (nameof Post.empty.PriorPermalinks) (fun (it: Permalink) -> it.Value) permalinks return! Custom.single $"""SELECT data ->> '{nameof Post.empty.Permalink}' AS permalink @@ -106,13 +104,14 @@ type PostgresPostData (log : ILogger) = /// Get a page of categorized posts for the given web log (excludes revisions) let findPageOfCategorizedPosts webLogId categoryIds pageNbr postsPerPage = log.LogTrace "Post.findPageOfCategorizedPosts" - let catSql, catParam = arrayContains (nameof Post.empty.CategoryIds) (_.Value) categoryIds + let catSql, catParam = + arrayContains (nameof Post.empty.CategoryIds) (fun (it: CategoryId) -> it.Value) categoryIds Custom.list $"{selectWithCriteria Table.Post} AND {catSql} ORDER BY data ->> '{nameof Post.empty.PublishedOn}' DESC LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}" - [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = PostStatus.toString Published |} + [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = Published.Value |} catParam ] fromData @@ -133,7 +132,7 @@ type PostgresPostData (log : ILogger) = $"{selectWithCriteria Table.Post} ORDER BY data ->> '{nameof Post.empty.PublishedOn}' DESC LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}" - [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = PostStatus.toString Published |} ] + [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = Published.Value |} ] fromData /// Get a page of tagged posts for the given web log (excludes revisions and prior permalinks) @@ -144,7 +143,7 @@ type PostgresPostData (log : ILogger) = AND data['{nameof Post.empty.Tags}'] @> @tag ORDER BY data ->> '{nameof Post.empty.PublishedOn}' DESC LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}" - [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = PostStatus.toString Published |} + [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = Published.Value |} "@tag", Query.jsonbDocParam [| tag |] ] fromData @@ -152,7 +151,7 @@ type PostgresPostData (log : ILogger) = let findSurroundingPosts webLogId publishedOn = backgroundTask { log.LogTrace "Post.findSurroundingPosts" let queryParams () = [ - "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = PostStatus.toString Published |} + "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = Published.Value |} "@publishedOn", Sql.string ((InstantPattern.General.Format publishedOn).Substring (0, 19)) ] let pubField = nameof Post.empty.PublishedOn @@ -188,10 +187,9 @@ type PostgresPostData (log : ILogger) = |> Sql.fromDataSource |> Sql.executeTransactionAsync [ Query.insert Table.Post, - posts - |> List.map (fun post -> Query.docParameters (PostId.toString post.Id) { post with Revisions = [] }) + posts |> List.map (fun post -> Query.docParameters post.Id.Value { post with Revisions = [] }) Revisions.insertSql Table.PostRevision, - revisions |> List.map (fun (postId, rev) -> Revisions.revParams postId PostId.toString rev) + revisions |> List.map (fun (postId, rev) -> Revisions.revParams postId (_.Value) rev) ] () } @@ -201,7 +199,7 @@ type PostgresPostData (log : ILogger) = log.LogTrace "Post.updatePriorPermalinks" match! postExists postId webLogId with | true -> - do! Update.partialById Table.Post (PostId.toString postId) {| PriorPermalinks = permalinks |} + do! Update.partialById Table.Post postId.Value {| PriorPermalinks = permalinks |} return true | false -> return false } diff --git a/src/MyWebLog.Data/Postgres/PostgresUploadData.fs b/src/MyWebLog.Data/Postgres/PostgresUploadData.fs index 97e36eb..136da11 100644 --- a/src/MyWebLog.Data/Postgres/PostgresUploadData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresUploadData.fs @@ -22,7 +22,7 @@ type PostgresUploadData (log : ILogger) = webLogIdParam upload.WebLogId typedParam "updatedOn" upload.UpdatedOn "@id", Sql.string (UploadId.toString upload.Id) - "@path", Sql.string (Permalink.toString upload.Path) + "@path", Sql.string upload.Path.Value "@data", Sql.bytea upload.Data ] diff --git a/src/MyWebLog.Data/RethinkDbData.fs b/src/MyWebLog.Data/RethinkDbData.fs index 73a625f..e653b1f 100644 --- a/src/MyWebLog.Data/RethinkDbData.fs +++ b/src/MyWebLog.Data/RethinkDbData.fs @@ -917,7 +917,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger return Result.Error $"Upload ID {UploadId.toString uploadId} not found" } diff --git a/src/MyWebLog.Data/SQLite/Helpers.fs b/src/MyWebLog.Data/SQLite/Helpers.fs index 08ab5a4..cdd31a2 100644 --- a/src/MyWebLog.Data/SQLite/Helpers.fs +++ b/src/MyWebLog.Data/SQLite/Helpers.fs @@ -254,7 +254,7 @@ module Map = Id = getString "id" rdr |> PostId WebLogId = getString "web_log_id" rdr |> WebLogId AuthorId = getString "author_id" rdr |> WebLogUserId - Status = getString "status" rdr |> PostStatus.parse + Status = getString "status" rdr |> PostStatus.Parse Title = getString "title" rdr Permalink = toPermalink rdr PublishedOn = tryInstant "published_on" rdr @@ -270,7 +270,7 @@ module Map = /// Create a revision from the current row in the given data reader let toRevision rdr : Revision = { AsOf = getInstant "as_of" rdr - Text = getString "revision_text" rdr |> MarkupText.parse + Text = getString "revision_text" rdr |> MarkupText.Parse } /// Create a tag mapping from the current row in the given data reader diff --git a/src/MyWebLog.Data/SQLite/SQLitePageData.fs b/src/MyWebLog.Data/SQLite/SQLitePageData.fs index 5562bcc..c3ae850 100644 --- a/src/MyWebLog.Data/SQLite/SQLitePageData.fs +++ b/src/MyWebLog.Data/SQLite/SQLitePageData.fs @@ -6,18 +6,18 @@ open MyWebLog open MyWebLog.Data open Newtonsoft.Json -/// SQLite myWebLog page data implementation -type SQLitePageData (conn : SqliteConnection, ser : JsonSerializer) = +/// SQLite myWebLog page data implementation +type SQLitePageData(conn: SqliteConnection, ser: JsonSerializer) = // SUPPORT FUNCTIONS /// Add parameters for page INSERT or UPDATE statements - let addPageParameters (cmd : SqliteCommand) (page : Page) = - [ cmd.Parameters.AddWithValue ("@id", PageId.toString page.Id) + let addPageParameters (cmd: SqliteCommand) (page: Page) = + [ cmd.Parameters.AddWithValue ("@id", page.Id.Value) 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 ("@permalink", page.Permalink.Value) cmd.Parameters.AddWithValue ("@publishedOn", instantParam page.PublishedOn) cmd.Parameters.AddWithValue ("@updatedOn", instantParam page.UpdatedOn) cmd.Parameters.AddWithValue ("@isInPageList", page.IsInPageList) @@ -30,7 +30,7 @@ type SQLitePageData (conn : SqliteConnection, ser : JsonSerializer) = /// 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.Parameters.AddWithValue ("@pageId", page.Id.Value) |> ignore cmd.CommandText <- "SELECT permalink FROM page_permalink WHERE page_id = @pageId" use! rdr = cmd.ExecuteReaderAsync () @@ -51,17 +51,17 @@ type SQLitePageData (conn : SqliteConnection, ser : JsonSerializer) = { toPage rdr with Text = "" } /// Update a page's prior permalinks - let updatePagePermalinks pageId oldLinks newLinks = backgroundTask { + let updatePagePermalinks (pageId: PageId) oldLinks newLinks = backgroundTask { let toDelete, toAdd = Utils.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.AddWithValue ("@pageId", pageId.Value) cmd.Parameters.Add ("@link", SqliteType.Text) ] |> ignore - let runCmd link = backgroundTask { - cmd.Parameters["@link"].Value <- Permalink.toString link + let runCmd (link: Permalink) = backgroundTask { + cmd.Parameters["@link"].Value <- link.Value do! write cmd } cmd.CommandText <- "DELETE FROM page_permalink WHERE page_id = @pageId AND permalink = @link" @@ -77,7 +77,7 @@ type SQLitePageData (conn : SqliteConnection, ser : JsonSerializer) = } /// Update a page's revisions - let updatePageRevisions pageId oldRevs newRevs = backgroundTask { + let updatePageRevisions (pageId: PageId) oldRevs newRevs = backgroundTask { let toDelete, toAdd = Utils.diffRevisions oldRevs newRevs if List.isEmpty toDelete && List.isEmpty toAdd then return () @@ -85,10 +85,10 @@ type SQLitePageData (conn : SqliteConnection, ser : JsonSerializer) = use cmd = conn.CreateCommand () let runCmd withText rev = backgroundTask { cmd.Parameters.Clear () - [ cmd.Parameters.AddWithValue ("@pageId", PageId.toString pageId) + [ cmd.Parameters.AddWithValue ("@pageId", pageId.Value) cmd.Parameters.AddWithValue ("@asOf", instantParam rev.AsOf) ] |> ignore - if withText then cmd.Parameters.AddWithValue ("@text", MarkupText.toString rev.Text) |> ignore + if withText then cmd.Parameters.AddWithValue ("@text", rev.Text.Value) |> ignore do! write cmd } cmd.CommandText <- "DELETE FROM page_revision WHERE page_id = @pageId AND as_of = @asOf" @@ -154,12 +154,12 @@ type SQLitePageData (conn : SqliteConnection, ser : JsonSerializer) = } /// Find a page by its ID (without revisions and prior permalinks) - let findById pageId webLogId = backgroundTask { + let findById (pageId: PageId) webLogId = backgroundTask { use cmd = conn.CreateCommand () cmd.CommandText <- "SELECT * FROM page WHERE id = @id" - cmd.Parameters.AddWithValue ("@id", PageId.toString pageId) |> ignore + cmd.Parameters.AddWithValue ("@id", pageId.Value) |> ignore use! rdr = cmd.ExecuteReaderAsync () - return Helpers.verifyWebLog webLogId (fun it -> it.WebLogId) (Map.toPage ser) rdr + return verifyWebLog webLogId (_.WebLogId) (Map.toPage ser) rdr } /// Find a complete page by its ID @@ -175,7 +175,7 @@ type SQLitePageData (conn : SqliteConnection, ser : JsonSerializer) = match! findById pageId webLogId with | Some _ -> use cmd = conn.CreateCommand () - cmd.Parameters.AddWithValue ("@id", PageId.toString pageId) |> ignore + cmd.Parameters.AddWithValue ("@id", pageId.Value) |> ignore cmd.CommandText <- "DELETE FROM page_revision WHERE page_id = @id; DELETE FROM page_permalink WHERE page_id = @id; @@ -186,11 +186,11 @@ type SQLitePageData (conn : SqliteConnection, ser : JsonSerializer) = } /// Find a page by its permalink for the given web log - let findByPermalink permalink webLogId = backgroundTask { + let findByPermalink (permalink: 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 + cmd.Parameters.AddWithValue ("@link", permalink.Value) |> ignore use! rdr = cmd.ExecuteReaderAsync () return if rdr.Read () then Some (toPage rdr) else None } @@ -198,7 +198,7 @@ type SQLitePageData (conn : SqliteConnection, ser : JsonSerializer) = /// Find the current permalink within a set of potential prior permalinks for the given web log let findCurrentPermalink permalinks webLogId = backgroundTask { use cmd = conn.CreateCommand () - let linkSql, linkParams = inClause "AND pp.permalink" "link" Permalink.toString permalinks + let linkSql, linkParams = inClause "AND pp.permalink" "link" (fun (it: Permalink) -> it.Value) permalinks cmd.CommandText <- $" SELECT p.permalink FROM page p diff --git a/src/MyWebLog.Data/SQLite/SQLitePostData.fs b/src/MyWebLog.Data/SQLite/SQLitePostData.fs index d73cf86..c12ecab 100644 --- a/src/MyWebLog.Data/SQLite/SQLitePostData.fs +++ b/src/MyWebLog.Data/SQLite/SQLitePostData.fs @@ -7,19 +7,19 @@ open MyWebLog.Data open Newtonsoft.Json open NodaTime -/// SQLite myWebLog post data implementation -type SQLitePostData (conn : SqliteConnection, ser : JsonSerializer) = +/// SQLite myWebLog post data implementation +type SQLitePostData(conn: SqliteConnection, ser: JsonSerializer) = // SUPPORT FUNCTIONS /// Add parameters for post INSERT or UPDATE statements - let addPostParameters (cmd : SqliteCommand) (post : Post) = - [ cmd.Parameters.AddWithValue ("@id", PostId.toString post.Id) + let addPostParameters (cmd: SqliteCommand) (post: Post) = + [ cmd.Parameters.AddWithValue ("@id", post.Id.Value) 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 ("@status", post.Status.Value) cmd.Parameters.AddWithValue ("@title", post.Title) - cmd.Parameters.AddWithValue ("@permalink", Permalink.toString post.Permalink) + cmd.Parameters.AddWithValue ("@permalink", post.Permalink.Value) cmd.Parameters.AddWithValue ("@publishedOn", maybeInstant post.PublishedOn) cmd.Parameters.AddWithValue ("@updatedOn", instantParam post.UpdatedOn) cmd.Parameters.AddWithValue ("@template", maybe post.Template) @@ -32,9 +32,9 @@ type SQLitePostData (conn : SqliteConnection, ser : JsonSerializer) = ] |> ignore /// Append category IDs and tags to a post - let appendPostCategoryAndTag (post : Post) = backgroundTask { + let appendPostCategoryAndTag (post: Post) = backgroundTask { use cmd = conn.CreateCommand () - cmd.Parameters.AddWithValue ("@id", PostId.toString post.Id) |> ignore + cmd.Parameters.AddWithValue ("@id", post.Id.Value) |> ignore cmd.CommandText <- "SELECT category_id AS id FROM post_category WHERE post_id = @id" use! rdr = cmd.ExecuteReaderAsync () @@ -47,9 +47,9 @@ type SQLitePostData (conn : SqliteConnection, ser : JsonSerializer) = } /// Append revisions and permalinks to a post - let appendPostRevisionsAndPermalinks (post : Post) = backgroundTask { + let appendPostRevisionsAndPermalinks (post: Post) = backgroundTask { use cmd = conn.CreateCommand () - cmd.Parameters.AddWithValue ("@postId", PostId.toString post.Id) |> ignore + cmd.Parameters.AddWithValue ("@postId", post.Id.Value) |> ignore cmd.CommandText <- "SELECT permalink FROM post_permalink WHERE post_id = @postId" use! rdr = cmd.ExecuteReaderAsync () @@ -69,12 +69,12 @@ type SQLitePostData (conn : SqliteConnection, ser : JsonSerializer) = Map.toPost ser /// Find just-the-post by its ID for the given web log (excludes category, tag, meta, revisions, and permalinks) - let findPostById postId webLogId = backgroundTask { + let findPostById (postId: PostId) webLogId = backgroundTask { use cmd = conn.CreateCommand () cmd.CommandText <- $"{selectPost} WHERE p.id = @id" - cmd.Parameters.AddWithValue ("@id", PostId.toString postId) |> ignore + cmd.Parameters.AddWithValue ("@id", postId.Value) |> ignore use! rdr = cmd.ExecuteReaderAsync () - return Helpers.verifyWebLog webLogId (fun p -> p.WebLogId) toPost rdr + return verifyWebLog webLogId (_.WebLogId) toPost rdr } /// Return a post with no revisions, prior permalinks, or text @@ -82,13 +82,13 @@ type SQLitePostData (conn : SqliteConnection, ser : JsonSerializer) = { toPost rdr with Text = "" } /// Update a post's assigned categories - let updatePostCategories postId oldCats newCats = backgroundTask { + let updatePostCategories (postId: PostId) oldCats newCats = backgroundTask { let toDelete, toAdd = Utils.diffLists oldCats newCats _.Value if List.isEmpty toDelete && List.isEmpty toAdd then return () else use cmd = conn.CreateCommand () - [ cmd.Parameters.AddWithValue ("@postId", PostId.toString postId) + [ cmd.Parameters.AddWithValue ("@postId", postId.Value) cmd.Parameters.Add ("@categoryId", SqliteType.Text) ] |> ignore let runCmd (catId: CategoryId) = backgroundTask { @@ -108,16 +108,16 @@ type SQLitePostData (conn : SqliteConnection, ser : JsonSerializer) = } /// Update a post's assigned categories - let updatePostTags postId (oldTags : string list) newTags = backgroundTask { + let updatePostTags (postId: PostId) (oldTags: string list) newTags = backgroundTask { let toDelete, toAdd = Utils.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.AddWithValue ("@postId", postId.Value) cmd.Parameters.Add ("@tag", SqliteType.Text) ] |> ignore - let runCmd (tag : string) = backgroundTask { + let runCmd (tag: string) = backgroundTask { cmd.Parameters["@tag"].Value <- tag do! write cmd } @@ -134,17 +134,17 @@ type SQLitePostData (conn : SqliteConnection, ser : JsonSerializer) = } /// Update a post's prior permalinks - let updatePostPermalinks postId oldLinks newLinks = backgroundTask { + let updatePostPermalinks (postId: PostId) oldLinks newLinks = backgroundTask { let toDelete, toAdd = Utils.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.AddWithValue ("@postId", postId.Value) cmd.Parameters.Add ("@link", SqliteType.Text) ] |> ignore - let runCmd link = backgroundTask { - cmd.Parameters["@link"].Value <- Permalink.toString link + let runCmd (link: Permalink) = backgroundTask { + cmd.Parameters["@link"].Value <- link.Value do! write cmd } cmd.CommandText <- "DELETE FROM post_permalink WHERE post_id = @postId AND permalink = @link" @@ -160,7 +160,7 @@ type SQLitePostData (conn : SqliteConnection, ser : JsonSerializer) = } /// Update a post's revisions - let updatePostRevisions postId oldRevs newRevs = backgroundTask { + let updatePostRevisions (postId: PostId) oldRevs newRevs = backgroundTask { let toDelete, toAdd = Utils.diffRevisions oldRevs newRevs if List.isEmpty toDelete && List.isEmpty toAdd then return () @@ -168,10 +168,10 @@ type SQLitePostData (conn : SqliteConnection, ser : JsonSerializer) = use cmd = conn.CreateCommand () let runCmd withText rev = backgroundTask { cmd.Parameters.Clear () - [ cmd.Parameters.AddWithValue ("@postId", PostId.toString postId) + [ cmd.Parameters.AddWithValue ("@postId", postId.Value) cmd.Parameters.AddWithValue ("@asOf", instantParam rev.AsOf) ] |> ignore - if withText then cmd.Parameters.AddWithValue ("@text", MarkupText.toString rev.Text) |> ignore + if withText then cmd.Parameters.AddWithValue ("@text", rev.Text.Value) |> ignore do! write cmd } cmd.CommandText <- "DELETE FROM post_revision WHERE post_id = @postId AND as_of = @asOf" @@ -208,11 +208,11 @@ type SQLitePostData (conn : SqliteConnection, ser : JsonSerializer) = } /// Count posts in a status for the given web log - let countByStatus status webLogId = backgroundTask { + let countByStatus (status: PostStatus) 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 + cmd.Parameters.AddWithValue ("@status", status.Value) |> ignore return! count cmd } @@ -226,11 +226,11 @@ type SQLitePostData (conn : SqliteConnection, ser : JsonSerializer) = } /// Find a post by its permalink for the given web log (excluding revisions and prior permalinks) - let findByPermalink permalink webLogId = backgroundTask { + let findByPermalink (permalink: Permalink) webLogId = backgroundTask { use cmd = conn.CreateCommand () cmd.CommandText <- $"{selectPost} WHERE p.web_log_id = @webLogId AND p.permalink = @link" addWebLogId cmd webLogId - cmd.Parameters.AddWithValue ("@link", Permalink.toString permalink) |> ignore + cmd.Parameters.AddWithValue ("@link", permalink.Value) |> ignore use! rdr = cmd.ExecuteReaderAsync () if rdr.Read () then let! post = appendPostCategoryAndTag (toPost rdr) @@ -253,7 +253,7 @@ type SQLitePostData (conn : SqliteConnection, ser : JsonSerializer) = match! findFullById postId webLogId with | Some _ -> use cmd = conn.CreateCommand () - cmd.Parameters.AddWithValue ("@id", PostId.toString postId) |> ignore + cmd.Parameters.AddWithValue ("@id", postId.Value) |> ignore cmd.CommandText <- "DELETE FROM post_revision WHERE post_id = @id; DELETE FROM post_permalink WHERE post_id = @id; @@ -269,7 +269,7 @@ type SQLitePostData (conn : SqliteConnection, ser : JsonSerializer) = /// Find the current permalink from a list of potential prior permalinks for the given web log let findCurrentPermalink permalinks webLogId = backgroundTask { use cmd = conn.CreateCommand () - let linkSql, linkParams = inClause "AND pp.permalink" "link" Permalink.toString permalinks + let linkSql, linkParams = inClause "AND pp.permalink" "link" (fun (it: Permalink) -> it.Value) permalinks cmd.CommandText <- $" SELECT p.permalink FROM post p @@ -301,7 +301,7 @@ type SQLitePostData (conn : SqliteConnection, ser : JsonSerializer) = /// Get a page of categorized posts for the given web log (excludes revisions and prior permalinks) let findPageOfCategorizedPosts webLogId categoryIds pageNbr postsPerPage = backgroundTask { use cmd = conn.CreateCommand () - let catSql, catParams = inClause "AND pc.category_id" "catId" (_.Value) categoryIds + let catSql, catParams = inClause "AND pc.category_id" "catId" (fun (it: CategoryId) -> it.Value) categoryIds cmd.CommandText <- $" {selectPost} INNER JOIN post_category pc ON pc.post_id = p.id @@ -311,7 +311,7 @@ type SQLitePostData (conn : SqliteConnection, ser : JsonSerializer) = ORDER BY published_on DESC LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}" addWebLogId cmd webLogId - cmd.Parameters.AddWithValue ("@status", PostStatus.toString Published) |> ignore + cmd.Parameters.AddWithValue ("@status", Published.Value) |> ignore cmd.Parameters.AddRange catParams use! rdr = cmd.ExecuteReaderAsync () let! posts = @@ -348,7 +348,7 @@ type SQLitePostData (conn : SqliteConnection, ser : JsonSerializer) = ORDER BY p.published_on DESC LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}" addWebLogId cmd webLogId - cmd.Parameters.AddWithValue ("@status", PostStatus.toString Published) |> ignore + cmd.Parameters.AddWithValue ("@status", Published.Value) |> ignore use! rdr = cmd.ExecuteReaderAsync () let! posts = toList toPost rdr @@ -369,7 +369,7 @@ type SQLitePostData (conn : SqliteConnection, ser : JsonSerializer) = ORDER BY p.published_on DESC LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}" addWebLogId cmd webLogId - [ cmd.Parameters.AddWithValue ("@status", PostStatus.toString Published) + [ cmd.Parameters.AddWithValue ("@status", Published.Value) cmd.Parameters.AddWithValue ("@tag", tag) ] |> ignore use! rdr = cmd.ExecuteReaderAsync () @@ -391,7 +391,7 @@ type SQLitePostData (conn : SqliteConnection, ser : JsonSerializer) = ORDER BY p.published_on DESC LIMIT 1" addWebLogId cmd webLogId - [ cmd.Parameters.AddWithValue ("@status", PostStatus.toString Published) + [ cmd.Parameters.AddWithValue ("@status", Published.Value) cmd.Parameters.AddWithValue ("@publishedOn", instantParam publishedOn) ] |> ignore use! rdr = cmd.ExecuteReaderAsync () diff --git a/src/MyWebLog.Data/SQLite/SQLiteUploadData.fs b/src/MyWebLog.Data/SQLite/SQLiteUploadData.fs index 886e113..3614b79 100644 --- a/src/MyWebLog.Data/SQLite/SQLiteUploadData.fs +++ b/src/MyWebLog.Data/SQLite/SQLiteUploadData.fs @@ -12,7 +12,7 @@ type SQLiteUploadData (conn : SqliteConnection) = let addUploadParameters (cmd : SqliteCommand) (upload : Upload) = [ cmd.Parameters.AddWithValue ("@id", UploadId.toString upload.Id) cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString upload.WebLogId) - cmd.Parameters.AddWithValue ("@path", Permalink.toString upload.Path) + cmd.Parameters.AddWithValue ("@path", upload.Path.Value) cmd.Parameters.AddWithValue ("@updatedOn", instantParam upload.UpdatedOn) cmd.Parameters.AddWithValue ("@dataLength", upload.Data.Length) ] |> ignore @@ -53,7 +53,7 @@ type SQLiteUploadData (conn : SqliteConnection) = do! rdr.CloseAsync () cmd.CommandText <- "DELETE FROM upload WHERE id = @id AND web_log_id = @webLogId" do! write cmd - return Ok (Permalink.toString upload.Path) + return Ok upload.Path.Value else return Error $"""Upload ID {cmd.Parameters["@id"]} not found""" } diff --git a/src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs b/src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs index f4b64e7..11a347c 100644 --- a/src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs +++ b/src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs @@ -46,7 +46,7 @@ type SQLiteWebLogData (conn : SqliteConnection, ser : JsonSerializer) = [ cmd.Parameters.AddWithValue ("@id", CustomFeedId.toString feed.Id) cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString webLogId) cmd.Parameters.AddWithValue ("@source", CustomFeedSource.toString feed.Source) - cmd.Parameters.AddWithValue ("@path", Permalink.toString feed.Path) + cmd.Parameters.AddWithValue ("@path", feed.Path.Value) cmd.Parameters.AddWithValue ("@podcast", maybe (if Option.isSome feed.Podcast then Some (Utils.serialize ser feed.Podcast) else None)) diff --git a/src/MyWebLog.Data/SQLiteData.fs b/src/MyWebLog.Data/SQLiteData.fs index 61d5f48..c25d9c2 100644 --- a/src/MyWebLog.Data/SQLiteData.fs +++ b/src/MyWebLog.Data/SQLiteData.fs @@ -195,7 +195,7 @@ type SQLiteData (conn : SqliteConnection, log : ILogger, ser : JsonS FundingUrl = Map.tryString "funding_url" podcastRdr FundingText = Map.tryString "funding_text" podcastRdr Medium = Map.tryString "medium" podcastRdr - |> Option.map PodcastMedium.parse + |> Option.map PodcastMedium.Parse } } |> List.ofSeq podcastRdr.Close () @@ -241,7 +241,7 @@ type SQLiteData (conn : SqliteConnection, log : ILogger, ser : JsonS |> List.iter (fun (postId, episode) -> cmd.CommandText <- "UPDATE post SET episode = @episode WHERE id = @id" [ cmd.Parameters.AddWithValue ("@episode", Utils.serialize ser episode) - cmd.Parameters.AddWithValue ("@id", PostId.toString postId) ] |> ignore + cmd.Parameters.AddWithValue ("@id", postId.Value) ] |> ignore let _ = cmd.ExecuteNonQuery () cmd.Parameters.Clear ()) diff --git a/src/MyWebLog.Data/Utils.fs b/src/MyWebLog.Data/Utils.fs index 14f94aa..285a5f2 100644 --- a/src/MyWebLog.Data/Utils.fs +++ b/src/MyWebLog.Data/Utils.fs @@ -34,11 +34,11 @@ let diffMetaItems (oldItems : MetaItem list) newItems = /// Find the permalinks added and removed let diffPermalinks oldLinks newLinks = - diffLists oldLinks newLinks Permalink.toString + diffLists oldLinks newLinks (fun (it: Permalink) -> it.Value) /// Find the revisions added and removed let diffRevisions oldRevs newRevs = - diffLists oldRevs newRevs (fun (rev: Revision) -> $"{rev.AsOf.ToUnixTimeTicks()}|{MarkupText.toString rev.Text}") + diffLists oldRevs newRevs (fun (rev: Revision) -> $"{rev.AsOf.ToUnixTimeTicks()}|{rev.Text.Value}") open MyWebLog.Converters open Newtonsoft.Json diff --git a/src/MyWebLog.Domain/DataTypes.fs b/src/MyWebLog.Domain/DataTypes.fs index baf4742..ad0c0d1 100644 --- a/src/MyWebLog.Domain/DataTypes.fs +++ b/src/MyWebLog.Domain/DataTypes.fs @@ -77,7 +77,7 @@ module Comment = /// An empty comment let empty = { Id = CommentId.Empty - PostId = PostId.empty + PostId = PostId.Empty InReplyToId = None Name = "" Email = "" @@ -136,11 +136,11 @@ module Page = /// An empty page let empty = { - Id = PageId.empty + Id = PageId.Empty WebLogId = WebLogId.empty AuthorId = WebLogUserId.empty Title = "" - Permalink = Permalink.empty + Permalink = Permalink.Empty PublishedOn = Noda.epoch UpdatedOn = Noda.epoch IsInPageList = false @@ -209,12 +209,12 @@ module Post = /// An empty post let empty = { - Id = PostId.empty + Id = PostId.Empty WebLogId = WebLogId.empty AuthorId = WebLogUserId.empty Status = Draft Title = "" - Permalink = Permalink.empty + Permalink = Permalink.Empty PublishedOn = None UpdatedOn = Noda.epoch Text = "" @@ -330,7 +330,7 @@ module Upload = let empty = { Id = UploadId.empty WebLogId = WebLogId.empty - Path = Permalink.empty + Path = Permalink.Empty UpdatedOn = Noda.epoch Data = [||] } @@ -406,16 +406,16 @@ module WebLog = $"{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}" + let absoluteUrl webLog (permalink: Permalink) = + $"{webLog.UrlBase}/{permalink.Value}" /// Generate a relative URL for the given link - let relativeUrl webLog permalink = + let relativeUrl webLog (permalink: Permalink) = let _, leadPath = hostAndPath webLog - $"{leadPath}/{Permalink.toString permalink}" + $"{leadPath}/{permalink.Value}" /// Convert an Instant (UTC reference) to the web log's local date/time - let localTime webLog (date : Instant) = + let localTime webLog (date: Instant) = match DateTimeZoneProviders.Tzdb[webLog.TimeZone] with | null -> date.ToDateTimeUtc() | tz -> date.InZone(tz).ToDateTimeUnspecified() diff --git a/src/MyWebLog.Domain/SupportTypes.fs b/src/MyWebLog.Domain/SupportTypes.fs index e6ac59c..ed25389 100644 --- a/src/MyWebLog.Domain/SupportTypes.fs +++ b/src/MyWebLog.Domain/SupportTypes.fs @@ -1,16 +1,22 @@ namespace MyWebLog open System +open Markdig open NodaTime /// Support functions for domain definition [] module private Helpers = + open Markdown.ColorCode + /// Create a new ID (short GUID) // https://www.madskristensen.net/blog/A-shorter-and-URL-friendly-GUID let newId () = Convert.ToBase64String(Guid.NewGuid().ToByteArray()).Replace('/', '_').Replace('+', '-')[..22] + + /// Pipeline with most extensions enabled + let markdownPipeline = MarkdownPipelineBuilder().UseSmartyPants().UseAdvancedExtensions().UseColorCode().Build() /// Functions to support NodaTime manipulation @@ -275,9 +281,6 @@ type Episode = { this.Duration |> Option.map (DurationPattern.CreateWithInvariantCulture("H:mm:ss").Format) -open Markdig -open Markdown.ColorCode - /// Types of markup text type MarkupText = /// Markdown text @@ -285,30 +288,27 @@ type MarkupText = /// 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) = + static member Parse(it: string) = match it with | text when text.StartsWith "Markdown: " -> Markdown text[10..] | text when text.StartsWith "HTML: " -> Html text[6..] | text -> invalidOp $"Cannot derive type of text ({text})" + + /// The source type for the markup text + member this.SourceType = + match this with Markdown _ -> "Markdown" | Html _ -> "HTML" + + /// The raw text, regardless of type + member this.Text = + match this with Markdown text -> text | Html text -> text + + /// The string representation of the markup text + member this.Value = $"{this.SourceType}: {this.Text}" + + /// The HTML representation of the markup text + member this.AsHtml() = + match this with Markdown text -> Markdown.ToHtml(text, markdownPipeline) | Html text -> text /// An item of metadata @@ -319,15 +319,13 @@ type MetaItem = { /// The metadata value Value : string -} - -/// Functions to support metadata items -module MetaItem = - +} with + /// An empty metadata item - let empty = + static member Empty = { Name = ""; Value = "" } + /// A revision of a page or post [] type Revision = { @@ -336,46 +334,45 @@ type Revision = { /// The text of the revision Text : MarkupText -} - -/// Functions to support revisions -module Revision = +} with /// An empty revision - let empty = + static member Empty = { AsOf = Noda.epoch; Text = Html "" } /// A permanent link -type Permalink = Permalink of string +[] +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 + static member Empty = Permalink "" + + /// The string value of this permalink + member this.Value = + match this with Permalink it -> it /// An identifier for a page -type PageId = PageId of string +[] +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 + static member Empty = PageId "" /// Create a new page ID - let create = newId >> PageId + static member Create = + newId >> PageId + + /// The string value of this page ID + member this.Value = + match this with PageId it -> it /// PodcastIndex.org podcast:medium allowed values +[] type PodcastMedium = | Podcast | Music @@ -385,87 +382,82 @@ type PodcastMedium = | Newsletter | Blog -/// Functions to support podcast medium -module PodcastMedium = - - /// Convert a podcast medium to a string - let toString = - function - | Podcast -> "podcast" - | Music -> "music" - | Video -> "video" - | Film -> "film" - | Audiobook -> "audiobook" - | Newsletter -> "newsletter" - | Blog -> "blog" - /// Parse a string into a podcast medium - let parse value = - match value with - | "podcast" -> Podcast - | "music" -> Music - | "video" -> Video - | "film" -> Film - | "audiobook" -> Audiobook + static member Parse = + function + | "podcast" -> Podcast + | "music" -> Music + | "video" -> Video + | "film" -> Film + | "audiobook" -> Audiobook | "newsletter" -> Newsletter - | "blog" -> Blog - | it -> invalidArg "medium" $"{it} is not a valid podcast medium" + | "blog" -> Blog + | it -> invalidArg "medium" $"{it} is not a valid podcast medium" + + /// The string value of this podcast medium + member this.Value = + match this with + | Podcast -> "podcast" + | Music -> "music" + | Video -> "video" + | Film -> "film" + | Audiobook -> "audiobook" + | Newsletter -> "newsletter" + | Blog -> "blog" /// 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 + static member Parse = + function | "Draft" -> Draft | "Published" -> Published - | it -> invalidArg "status" $"{it} is not a valid post status" + | it -> invalidArg "status" $"{it} is not a valid post status" + + /// The string representation of this post status + member this.Value = + match this with Draft -> "Draft" | Published -> "Published" /// An identifier for a post -type PostId = PostId of string +[] +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 + static member Empty = PostId "" /// Create a new post ID - let create = newId >> PostId + static member Create = + newId >> PostId + + /// Convert a post ID to a string + member this.Value = + match this with PostId it -> it /// A redirection for a previously valid URL +[] type RedirectRule = { /// The From string or pattern - From : string + From: string /// The To string or pattern - To : string + To: string /// Whether to use regular expressions on this rule - IsRegex : bool -} - -/// Functions to support redirect rules -module RedirectRule = - + IsRegex: bool +} with + /// An empty redirect rule - let empty = { + static member Empty = { From = "" To = "" IsRegex = false diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index 28c85e5..2f9a176 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -89,14 +89,14 @@ type DisplayCustomFeed = { module DisplayCustomFeed = /// Create a display version from a custom feed - let fromFeed (cats : DisplayCategory[]) (feed : CustomFeed) : DisplayCustomFeed = + let fromFeed (cats: DisplayCategory[]) (feed: CustomFeed) : DisplayCustomFeed = let source = match feed.Source with | Category (CategoryId catId) -> $"Category: {(cats |> Array.find (fun cat -> cat.Id = catId)).Name}" | Tag tag -> $"Tag: {tag}" { Id = CustomFeedId.toString feed.Id Source = source - Path = Permalink.toString feed.Path + Path = feed.Path.Value IsPodcast = Option.isSome feed.Podcast } @@ -136,32 +136,30 @@ type DisplayPage = } /// 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 - AuthorId = WebLogUserId.toString page.AuthorId - Title = page.Title - Permalink = Permalink.toString page.Permalink - PublishedOn = WebLog.localTime webLog page.PublishedOn - UpdatedOn = WebLog.localTime webLog page.UpdatedOn - IsInPageList = page.IsInPageList - IsDefault = pageId = webLog.DefaultPage - Text = "" - Metadata = [] - } + static member FromPageMinimal webLog (page: Page) = { + Id = page.Id.Value + AuthorId = WebLogUserId.toString page.AuthorId + Title = page.Title + Permalink = page.Permalink.Value + PublishedOn = WebLog.localTime webLog page.PublishedOn + UpdatedOn = WebLog.localTime webLog page.UpdatedOn + IsInPageList = page.IsInPageList + IsDefault = page.Id.Value = 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 + { Id = page.Id.Value AuthorId = WebLogUserId.toString page.AuthorId Title = page.Title - Permalink = Permalink.toString page.Permalink + Permalink = page.Permalink.Value PublishedOn = WebLog.localTime webLog page.PublishedOn UpdatedOn = WebLog.localTime webLog page.UpdatedOn IsInPageList = page.IsInPageList - IsDefault = pageId = webLog.DefaultPage + IsDefault = page.Id.Value = webLog.DefaultPage Text = addBaseToRelativeUrls extra page.Text Metadata = page.Metadata } @@ -187,7 +185,7 @@ module DisplayRevision = let fromRevision webLog (rev : Revision) = { AsOf = rev.AsOf.ToDateTimeUtc () AsOfLocal = WebLog.localTime webLog rev.AsOf - Format = MarkupText.sourceType rev.Text + Format = rev.Text.SourceType } @@ -253,7 +251,7 @@ module DisplayUpload = /// Create a display uploaded file let fromUpload webLog source (upload : Upload) = - let path = Permalink.toString upload.Path + let path = upload.Path.Value let name = Path.GetFileName path { Id = UploadId.toString upload.Id Name = name @@ -436,13 +434,13 @@ type EditCustomFeedModel = } /// Create a model from a custom feed - static member fromFeed (feed : CustomFeed) = + 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 + Path = feed.Path.Value } match feed.Podcast with | Some p -> @@ -454,7 +452,7 @@ type EditCustomFeedModel = Summary = p.Summary DisplayedAuthor = p.DisplayedAuthor Email = p.Email - ImageUrl = Permalink.toString p.ImageUrl + ImageUrl = p.ImageUrl.Value AppleCategory = p.AppleCategory AppleSubcategory = defaultArg p.AppleSubcategory "" Explicit = p.Explicit.Value @@ -462,10 +460,8 @@ type EditCustomFeedModel = MediaBaseUrl = defaultArg p.MediaBaseUrl "" FundingUrl = defaultArg p.FundingUrl "" FundingText = defaultArg p.FundingText "" - PodcastGuid = p.PodcastGuid - |> Option.map (fun it -> it.ToString().ToLowerInvariant ()) - |> Option.defaultValue "" - Medium = p.Medium |> Option.map PodcastMedium.toString |> Option.defaultValue "" + PodcastGuid = p.PodcastGuid |> Option.map _.ToString().ToLowerInvariant() |> Option.defaultValue "" + Medium = p.Medium |> Option.map _.Value |> Option.defaultValue "" } | None -> rss @@ -492,7 +488,7 @@ type EditCustomFeedModel = PodcastGuid = noneIfBlank this.PodcastGuid |> Option.map Guid.Parse FundingUrl = noneIfBlank this.FundingUrl FundingText = noneIfBlank this.FundingText - Medium = noneIfBlank this.Medium |> Option.map PodcastMedium.parse + Medium = noneIfBlank this.Medium |> Option.map PodcastMedium.Parse } else None @@ -530,61 +526,61 @@ type EditMyInfoModel = /// View model to edit a page [] -type EditPageModel = - { /// The ID of the page being edited - PageId : string +type EditPageModel = { + /// The ID of the page being edited + PageId: string - /// The title of the page - Title : string + /// The title of the page + Title: string - /// The permalink for the page - Permalink : 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 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 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[] - } + /// The text of the page + Text: string + + /// Names of metadata items + MetaNames: string array + + /// Values of metadata items + MetaValues: string array +} with /// Create an edit model from an existing page - static member fromPage (page : Page) = + static member fromPage (page: Page) = let latest = - match page.Revisions |> List.sortByDescending (fun r -> r.AsOf) |> List.tryHead with + match page.Revisions |> List.sortByDescending _.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 + | None -> Revision.Empty + let page = if page.Metadata |> List.isEmpty then { page with Metadata = [ MetaItem.Empty ] } else page + { PageId = page.Id.Value Title = page.Title - Permalink = Permalink.toString page.Permalink + Permalink = page.Permalink.Value Template = defaultArg page.Template "" IsShownInPageList = page.IsInPageList - 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 + Source = latest.Text.SourceType + Text = latest.Text.Text + MetaNames = page.Metadata |> List.map _.Name |> Array.ofList + MetaValues = page.Metadata |> List.map _.Value |> Array.ofList } /// Whether this is a new page member this.IsNew = this.PageId = "new" /// Update a page with values from this model - member this.UpdatePage (page : Page) now = - let revision = { AsOf = now; Text = MarkupText.parse $"{this.Source}: {this.Text}" } + member this.UpdatePage (page: Page) now = + let revision = { AsOf = now; Text = MarkupText.Parse $"{this.Source}: {this.Text}" } // Detect a permalink change, and add the prior one to the prior list - match Permalink.toString page.Permalink with + match page.Permalink.Value with | "" -> page | link when link = this.Permalink -> page | _ -> { page with PriorPermalinks = page.Permalink :: page.PriorPermalinks } @@ -596,7 +592,7 @@ type EditPageModel = UpdatedOn = now IsInPageList = this.IsShownInPageList Template = match this.Template with "" -> None | tmpl -> Some tmpl - Text = MarkupText.toHtml revision.Text + Text = revision.Text.AsHtml() Metadata = Seq.zip this.MetaNames this.MetaValues |> Seq.filter (fun it -> fst it > "") |> Seq.map (fun it -> { Name = fst it; Value = snd it }) @@ -610,127 +606,127 @@ type EditPageModel = /// View model to edit a post [] -type EditPostModel = - { /// The ID of the post being edited - PostId : string +type EditPostModel = { + /// The ID of the post being edited + PostId: string - /// The title of the post - Title : string + /// The title of the post + Title: string - /// The permalink for the post - Permalink : string + /// The permalink for the post + Permalink: string - /// The source format for the text - Source : 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 - - /// Whether this post has a podcast episode - IsEpisode : bool - - /// The URL for the media for this episode (may be permalink) - Media : string - - /// The size (in bytes) of the media for this episode - Length : int64 - - /// The duration of the media for this episode - Duration : string - - /// The media type (optional, defaults to podcast-defined media type) - MediaType : string - - /// The URL for the image for this episode (may be permalink; optional, defaults to podcast image) - ImageUrl : string - - /// A subtitle for the episode (optional) - Subtitle : string - - /// The explicit rating for this episode (optional, defaults to podcast setting) - Explicit : string - - /// The URL for the chapter file for the episode (may be permalink; optional) - ChapterFile : string - - /// The type of the chapter file (optional; defaults to application/json+chapters if chapterFile is provided) - ChapterType : string - - /// The URL for the transcript (may be permalink; optional) - TranscriptUrl : string - - /// The MIME type for the transcript (optional, recommended if transcriptUrl is provided) - TranscriptType : string - - /// The language of the transcript (optional) - TranscriptLang : string - - /// Whether the provided transcript should be presented as captions - TranscriptCaptions : bool - - /// The season number (optional) - SeasonNumber : int - - /// A description of this season (optional, ignored if season number is not provided) - SeasonDescription : string - - /// The episode number (decimal; optional) - EpisodeNumber : string - - /// A description of this episode (optional, ignored if episode number is not provided) - EpisodeDescription : 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 array + + /// The post status + Status: string + + /// Whether this post should be published + DoPublish: bool + + /// Names of metadata items + MetaNames: string array + + /// Values of metadata items + MetaValues: string array + + /// 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 + + /// Whether this post has a podcast episode + IsEpisode: bool + + /// The URL for the media for this episode (may be permalink) + Media: string + + /// The size (in bytes) of the media for this episode + Length: int64 + + /// The duration of the media for this episode + Duration: string + + /// The media type (optional, defaults to podcast-defined media type) + MediaType: string + + /// The URL for the image for this episode (may be permalink; optional, defaults to podcast image) + ImageUrl: string + + /// A subtitle for the episode (optional) + Subtitle: string + + /// The explicit rating for this episode (optional, defaults to podcast setting) + Explicit: string + + /// The URL for the chapter file for the episode (may be permalink; optional) + ChapterFile: string + + /// The type of the chapter file (optional; defaults to application/json+chapters if chapterFile is provided) + ChapterType: string + + /// The URL for the transcript (may be permalink; optional) + TranscriptUrl: string + + /// The MIME type for the transcript (optional, recommended if transcriptUrl is provided) + TranscriptType: string + + /// The language of the transcript (optional) + TranscriptLang: string + + /// Whether the provided transcript should be presented as captions + TranscriptCaptions: bool + + /// The season number (optional) + SeasonNumber: int + + /// A description of this season (optional, ignored if season number is not provided) + SeasonDescription: string + + /// The episode number (decimal; optional) + EpisodeNumber: string + + /// A description of this episode (optional, ignored if episode number is not provided) + EpisodeDescription: string +} with /// Create an edit model from an existing past - static member fromPost webLog (post : Post) = + static member fromPost webLog (post: Post) = let latest = - match post.Revisions |> List.sortByDescending (_.AsOf) |> List.tryHead with + match post.Revisions |> List.sortByDescending _.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 + | None -> Revision.Empty + let post = if post.Metadata |> List.isEmpty then { post with Metadata = [ MetaItem.Empty ] } else post let episode = defaultArg post.Episode Episode.Empty - { PostId = PostId.toString post.Id + { PostId = post.Id.Value Title = post.Title - Permalink = Permalink.toString post.Permalink - Source = MarkupText.sourceType latest.Text - Text = MarkupText.text latest.Text + Permalink = post.Permalink.Value + Source = latest.Text.SourceType + Text = latest.Text.Text Tags = String.Join (", ", post.Tags) Template = defaultArg post.Template "" - CategoryIds = post.CategoryIds |> List.map (_.Value) |> Array.ofList - Status = PostStatus.toString post.Status + CategoryIds = post.CategoryIds |> List.map _.Value |> Array.ofList + Status = post.Status.Value DoPublish = false - MetaNames = post.Metadata |> List.map (_.Name) |> Array.ofList - MetaValues = post.Metadata |> List.map (_.Value) |> Array.ofList + MetaNames = post.Metadata |> List.map _.Name |> Array.ofList + MetaValues = post.Metadata |> List.map _.Value |> Array.ofList SetPublished = false PubOverride = post.PublishedOn |> Option.map (WebLog.localTime webLog) |> Option.toNullable SetUpdated = false @@ -741,7 +737,7 @@ type EditPostModel = MediaType = defaultArg episode.MediaType "" ImageUrl = defaultArg episode.ImageUrl "" Subtitle = defaultArg episode.Subtitle "" - Explicit = defaultArg (episode.Explicit |> Option.map (_.Value)) "" + Explicit = defaultArg (episode.Explicit |> Option.map _.Value) "" ChapterFile = defaultArg episode.ChapterFile "" ChapterType = defaultArg episode.ChapterType "" TranscriptUrl = defaultArg episode.TranscriptUrl "" @@ -758,10 +754,10 @@ type EditPostModel = member this.IsNew = this.PostId = "new" /// Update a post with values from the submitted form - member this.UpdatePost (post : Post) now = - let revision = { AsOf = now; Text = MarkupText.parse $"{this.Source}: {this.Text}" } + member this.UpdatePost (post: Post) now = + let revision = { AsOf = now; Text = MarkupText.Parse $"{this.Source}: {this.Text}" } // Detect a permalink change, and add the prior one to the prior list - match Permalink.toString post.Permalink with + match post.Permalink.Value with | "" -> post | link when link = this.Permalink -> post | _ -> { post with PriorPermalinks = post.Permalink :: post.PriorPermalinks } @@ -772,7 +768,7 @@ type EditPostModel = Permalink = Permalink this.Permalink PublishedOn = if this.DoPublish then Some now else post.PublishedOn UpdatedOn = now - Text = MarkupText.toHtml revision.Text + Text = revision.Text.AsHtml() Tags = this.Tags.Split "," |> Seq.ofArray |> Seq.map (fun it -> it.Trim().ToLower ()) @@ -1005,39 +1001,39 @@ type LogOnModel = /// 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[] - } +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 array +} with /// Create a permalink model from a page - static member fromPage (pg : Page) = - { Id = PageId.toString pg.Id + static member fromPage (pg: Page) = + { Id = pg.Id.Value Entity = "page" CurrentTitle = pg.Title - CurrentPermalink = Permalink.toString pg.Permalink - Prior = pg.PriorPermalinks |> List.map Permalink.toString |> Array.ofList + CurrentPermalink = pg.Permalink.Value + Prior = pg.PriorPermalinks |> List.map _.Value |> Array.ofList } /// Create a permalink model from a post - static member fromPost (post : Post) = - { Id = PostId.toString post.Id + static member fromPost (post: Post) = + { Id = post.Id.Value Entity = "post" CurrentTitle = post.Title - CurrentPermalink = Permalink.toString post.Permalink - Prior = post.PriorPermalinks |> List.map Permalink.toString |> Array.ofList + CurrentPermalink = post.Permalink.Value + Prior = post.PriorPermalinks |> List.map _.Value |> Array.ofList } @@ -1054,20 +1050,20 @@ type ManageRevisionsModel = CurrentTitle : string /// The revisions for the page or post - Revisions : DisplayRevision[] + Revisions : DisplayRevision array } /// Create a revision model from a page - static member fromPage webLog (pg : Page) = - { Id = PageId.toString pg.Id + static member fromPage webLog (pg: Page) = + { Id = pg.Id.Value Entity = "page" CurrentTitle = pg.Title Revisions = pg.Revisions |> List.map (DisplayRevision.fromRevision webLog) |> Array.ofList } /// Create a revision model from a post - static member fromPost webLog (post : Post) = - { Id = PostId.toString post.Id + static member fromPost webLog (post: Post) = + { Id = post.Id.Value Entity = "post" CurrentTitle = post.Title Revisions = post.Revisions |> List.map (DisplayRevision.fromRevision webLog) |> Array.ofList @@ -1076,53 +1072,53 @@ type ManageRevisionsModel = /// 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 - - /// The podcast episode information for this post - Episode : Episode option - - /// Metadata for the post - Metadata : MetaItem 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 + + /// The podcast episode information for this post + Episode: Episode option + + /// Metadata for the post + Metadata: MetaItem list +} with /// Create a post list item from a post - static member fromPost (webLog : WebLog) (post : Post) = + static member fromPost (webLog: WebLog) (post: Post) = let _, extra = WebLog.hostAndPath webLog let inTZ = WebLog.localTime webLog - { Id = PostId.toString post.Id + { Id = post.Id.Value AuthorId = WebLogUserId.toString post.AuthorId - Status = PostStatus.toString post.Status + Status = post.Status.Value Title = post.Title - Permalink = Permalink.toString post.Permalink + Permalink = post.Permalink.Value PublishedOn = post.PublishedOn |> Option.map inTZ |> Option.toNullable UpdatedOn = inTZ post.UpdatedOn Text = addBaseToRelativeUrls extra post.Text diff --git a/src/MyWebLog/DotLiquidBespoke.fs b/src/MyWebLog/DotLiquidBespoke.fs index 83e9a3f..45006b7 100644 --- a/src/MyWebLog/DotLiquidBespoke.fs +++ b/src/MyWebLog/DotLiquidBespoke.fs @@ -101,10 +101,10 @@ type ThemeAssetFilter () = /// Create various items in the page header based on the state of the page being generated -type PageHeadTag () = - inherit Tag () +type PageHeadTag() = + inherit Tag() - override this.Render (context : Context, result : TextWriter) = + override this.Render(context: Context, result: TextWriter) = let webLog = context.WebLog // spacer let s = " " @@ -115,9 +115,9 @@ type PageHeadTag () = // Theme assets if assetExists "style.css" webLog then - result.WriteLine $"""{s}""" + result.WriteLine $"""{s}""" if assetExists "favicon.ico" webLog then - result.WriteLine $"""{s}""" + result.WriteLine $"""{s}""" // RSS feeds and canonical URLs let feedLink title url = @@ -126,16 +126,16 @@ type PageHeadTag () = $"""{s}""" if webLog.Rss.IsFeedEnabled && getBool "is_home" then - result.WriteLine (feedLink webLog.Name webLog.Rss.FeedName) - result.WriteLine $"""{s}""" + result.WriteLine(feedLink webLog.Name webLog.Rss.FeedName) + result.WriteLine $"""{s}""" if webLog.Rss.IsCategoryEnabled && getBool "is_category_home" then let slug = context.Environments[0].["slug"] :?> string - result.WriteLine (feedLink webLog.Name $"category/{slug}/{webLog.Rss.FeedName}") + result.WriteLine(feedLink webLog.Name $"category/{slug}/{webLog.Rss.FeedName}") if webLog.Rss.IsTagEnabled && getBool "is_tag_home" then let slug = context.Environments[0].["slug"] :?> string - result.WriteLine (feedLink webLog.Name $"tag/{slug}/{webLog.Rss.FeedName}") + result.WriteLine(feedLink webLog.Name $"tag/{slug}/{webLog.Rss.FeedName}") if getBool "is_post" then let post = context.Environments[0].["model"] :?> PostDisplay diff --git a/src/MyWebLog/Handlers/Admin.fs b/src/MyWebLog/Handlers/Admin.fs index de59270..0e9af33 100644 --- a/src/MyWebLog/Handlers/Admin.fs +++ b/src/MyWebLog/Handlers/Admin.fs @@ -233,7 +233,7 @@ module RedirectRules = if idx = -1 then return! hashForPage "Add Redirect Rule" - |> addToHash "model" (EditRedirectRuleModel.fromRule -1 RedirectRule.empty) + |> addToHash "model" (EditRedirectRuleModel.fromRule -1 RedirectRule.Empty) |> withAntiCsrf ctx |> adminBareView "redirect-edit" next ctx else @@ -260,7 +260,7 @@ module RedirectRules = let! model = ctx.BindFormAsync () let isNew = idx = -1 let rules = ctx.WebLog.RedirectRules - let rule = model.UpdateRule (if isNew then RedirectRule.empty else List.item idx rules) + let rule = model.UpdateRule (if isNew then RedirectRule.Empty else List.item idx rules) let newRules = match isNew with | true when model.InsertAtTop -> List.insertAt 0 rule rules @@ -545,7 +545,7 @@ module WebLog = match! TemplateCache.get adminTheme "tag-mapping-list-body" ctx.Data with | Ok tagMapTemplate -> let! allPages = data.Page.All ctx.WebLog.Id - let! themes = data.Theme.All () + let! themes = data.Theme.All() let! users = data.WebLogUser.FindByWebLog ctx.WebLog.Id let! hash = hashForPage "Web Log Settings" @@ -553,10 +553,10 @@ module WebLog = |> addToHash ViewContext.Model (SettingsModel.fromWebLog ctx.WebLog) |> addToHash "pages" ( seq { - KeyValuePair.Create ("posts", "- First Page of Posts -") + 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)) + |> List.sortBy _.Title.ToLower() + |> List.map (fun p -> KeyValuePair.Create(p.Id.Value, p.Title)) } |> Array.ofSeq) |> addToHash "themes" ( diff --git a/src/MyWebLog/Handlers/Feed.fs b/src/MyWebLog/Handlers/Feed.fs index 2db2de4..1d8dcda 100644 --- a/src/MyWebLog/Handlers/Feed.fs +++ b/src/MyWebLog/Handlers/Feed.fs @@ -37,13 +37,12 @@ let deriveFeedType (ctx : HttpContext) feedPath : (FeedType * int) option = | 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 + |> List.tryFind (fun it -> feedPath.EndsWith it.Path.Value) with | Some feed -> debug (fun () -> "Found custom feed") - Some (Custom (feed, feedPath), - feed.Podcast |> Option.map (fun p -> p.ItemsInFeed) |> Option.defaultValue postCount) + Some (Custom (feed, feedPath), feed.Podcast |> Option.map _.ItemsInFeed |> Option.defaultValue postCount) | None -> - debug (fun () -> $"No matching feed found") + debug (fun () -> "No matching feed found") None /// Determine the function to retrieve posts for the given feed @@ -142,7 +141,7 @@ let private addEpisode webLog (podcast : PodcastOptions) (episode : Episode) (po | link when Option.isSome podcast.MediaBaseUrl -> $"{podcast.MediaBaseUrl.Value}{link}" | link -> WebLog.absoluteUrl webLog (Permalink link) let epMediaType = [ episode.MediaType; podcast.DefaultMediaType ] |> List.tryFind Option.isSome |> Option.flatten - let epImageUrl = defaultArg episode.ImageUrl (Permalink.toString podcast.ImageUrl) |> toAbsolute webLog + let epImageUrl = defaultArg episode.ImageUrl podcast.ImageUrl.Value |> toAbsolute webLog let epExplicit = (defaultArg episode.Explicit podcast.Explicit).Value let xmlDoc = XmlDocument() @@ -310,8 +309,7 @@ let private addPodcast webLog (rssFeed : SyndicationFeed) (feed : CustomFeed) = podcast.PodcastGuid |> Option.iter (fun guid -> rssFeed.ElementExtensions.Add("guid", Namespace.podcast, guid.ToString().ToLowerInvariant())) - podcast.Medium - |> Option.iter (fun med -> rssFeed.ElementExtensions.Add("medium", Namespace.podcast, PodcastMedium.toString med)) + podcast.Medium |> Option.iter (fun med -> rssFeed.ElementExtensions.Add("medium", Namespace.podcast, med.Value)) /// Get the feed's self reference and non-feed link let private selfAndLink webLog feedType ctx = @@ -370,7 +368,7 @@ let createFeed (feedType : FeedType) posts : HttpHandler = fun next ctx -> backg match podcast, post.Episode with | Some feed, Some episode -> addEpisode webLog (Option.get feed.Podcast) episode post item | Some _, _ -> - warn "Feed" ctx $"[{webLog.Name} {Permalink.toString self}] \"{stripHtml post.Title}\" has no media" + warn "Feed" ctx $"[{webLog.Name} {self.Value}] \"{stripHtml post.Title}\" has no media" item | _ -> item @@ -437,14 +435,14 @@ let editCustomFeed feedId : HttpHandler = requireAccess WebLogAdmin >=> fun next |> withAntiCsrf ctx |> addToHash ViewContext.Model (EditCustomFeedModel.fromFeed f) |> addToHash "medium_values" [| - KeyValuePair.Create ("", "– Unspecified –") - KeyValuePair.Create (PodcastMedium.toString Podcast, "Podcast") - KeyValuePair.Create (PodcastMedium.toString Music, "Music") - KeyValuePair.Create (PodcastMedium.toString Video, "Video") - KeyValuePair.Create (PodcastMedium.toString Film, "Film") - KeyValuePair.Create (PodcastMedium.toString Audiobook, "Audiobook") - KeyValuePair.Create (PodcastMedium.toString Newsletter, "Newsletter") - KeyValuePair.Create (PodcastMedium.toString Blog, "Blog") + KeyValuePair.Create("", "– Unspecified –") + KeyValuePair.Create(Podcast.Value, "Podcast") + KeyValuePair.Create(Music.Value, "Music") + KeyValuePair.Create(Video.Value, "Video") + KeyValuePair.Create(Film.Value, "Film") + KeyValuePair.Create(Audiobook.Value, "Audiobook") + KeyValuePair.Create(Newsletter.Value, "Newsletter") + KeyValuePair.Create(Blog.Value, "Blog") |] |> adminView "custom-feed-edit" next ctx | None -> Error.notFound next ctx diff --git a/src/MyWebLog/Handlers/Page.fs b/src/MyWebLog/Handlers/Page.fs index cfdebbd..1eece85 100644 --- a/src/MyWebLog/Handlers/Page.fs +++ b/src/MyWebLog/Handlers/Page.fs @@ -133,7 +133,7 @@ let previewRevision (pgId, revDate) : HttpHandler = requireAccess Author >=> fun return! {| content = [ """
""" - (MarkupText.toHtml >> addBaseToRelativeUrls extra) rev.Text + rev.Text.AsHtml() |> addBaseToRelativeUrls extra "
" ] |> String.concat "" @@ -174,13 +174,13 @@ let deleteRevision (pgId, revDate) : HttpHandler = requireAccess Author >=> fun // POST /admin/page/save let save : HttpHandler = requireAccess Author >=> fun next ctx -> task { - let! model = ctx.BindFormAsync () + let! model = ctx.BindFormAsync() let data = ctx.Data let now = Noda.now () let tryPage = if model.IsNew then { Page.empty with - Id = PageId.create () + Id = PageId.Create() WebLogId = ctx.WebLog.Id AuthorId = ctx.UserId PublishedOn = now @@ -193,7 +193,7 @@ let save : HttpHandler = requireAccess Author >=> fun next ctx -> task { do! (if model.IsNew then data.Page.Add else data.Page.Update) updatedPage if updateList then do! PageListCache.update ctx do! addMessage ctx { UserMessage.success with Message = "Page saved successfully" } - return! redirectToGet $"admin/page/{PageId.toString page.Id}/edit" next ctx + return! redirectToGet $"admin/page/{page.Id.Value}/edit" next ctx | Some _ -> return! Error.notAuthorized next ctx | None -> return! Error.notFound next ctx } diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index 0f5ea97..d9e87cd 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -39,7 +39,7 @@ 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 (data : IData) = task { +let preparePostList webLog posts listType (url: string) pageNbr perPage (data: IData) = task { let! authors = getAuthors webLog posts data let! tagMappings = getTagMappings webLog posts data let relUrl it = Some <| WebLog.relativeUrl webLog (Permalink it) @@ -58,7 +58,7 @@ let preparePostList webLog posts listType (url : string) pageNbr perPage (data : | _ -> Task.FromResult (None, None) let newerLink = match listType, pageNbr with - | SinglePost, _ -> newerPost |> Option.map (fun p -> Permalink.toString p.Permalink) + | SinglePost, _ -> newerPost |> Option.map _.Permalink.Value | _, 1 -> None | PostList, 2 when webLog.DefaultPage = "posts" -> Some "" | PostList, _ -> relUrl $"page/{pageNbr - 1}" @@ -70,7 +70,7 @@ let preparePostList webLog posts listType (url : string) pageNbr perPage (data : | 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) + | SinglePost, _ -> olderPost |> Option.map _.Permalink.Value | _, false -> None | PostList, true -> relUrl $"page/{pageNbr + 1}" | CategoryList, true -> relUrl $"category/{url}/page/{pageNbr + 1}" @@ -81,9 +81,9 @@ let preparePostList webLog posts listType (url : string) pageNbr perPage (data : Authors = authors Subtitle = None NewerLink = newerLink - NewerName = newerPost |> Option.map (fun p -> p.Title) + NewerName = newerPost |> Option.map _.Title OlderLink = olderLink - OlderName = olderPost |> Option.map (fun p -> p.Title) + OlderName = olderPost |> Option.map _.Title } return makeHash {||} @@ -333,7 +333,7 @@ let previewRevision (postId, revDate) : HttpHandler = requireAccess Author >=> f return! {| content = [ """
""" - (MarkupText.toHtml >> addBaseToRelativeUrls extra) rev.Text + rev.Text.AsHtml() |> addBaseToRelativeUrls extra "
" ] |> String.concat "" @@ -374,12 +374,12 @@ let deleteRevision (postId, revDate) : HttpHandler = requireAccess Author >=> fu // POST /admin/post/save let save : HttpHandler = requireAccess Author >=> fun next ctx -> task { - let! model = ctx.BindFormAsync () + let! model = ctx.BindFormAsync() let data = ctx.Data let tryPost = if model.IsNew then { Post.empty with - Id = PostId.create () + Id = PostId.Create() WebLogId = ctx.WebLog.Id AuthorId = ctx.UserId } |> someTask @@ -410,7 +410,7 @@ let save : HttpHandler = requireAccess Author >=> fun next ctx -> task { |> List.length = List.length priorCats) then do! CategoryCache.update ctx do! addMessage ctx { UserMessage.success with Message = "Post saved successfully" } - return! redirectToGet $"admin/post/{PostId.toString post.Id}/edit" next ctx + return! redirectToGet $"admin/post/{post.Id.Value}/edit" next ctx | Some _ -> return! Error.notAuthorized next ctx | None -> return! Error.notFound next ctx } diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index 5c6d371..c128864 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -11,20 +11,20 @@ module CatchAll = open MyWebLog.ViewModels /// Sequence where the first returned value is the proper handler for the link - let private deriveAction (ctx : HttpContext) : HttpHandler seq = + 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 () + (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) + if textLink = "" then yield redirectTo true (WebLog.relativeUrl webLog Permalink.Empty) + let permalink = Permalink textLink[1..] // Current post match data.Post.FindByPermalink permalink webLog.Id |> await with | Some post -> @@ -80,7 +80,7 @@ module CatchAll = } // GET {all-of-the-above} - let route : HttpHandler = fun next ctx -> + let route: HttpHandler = fun next ctx -> match deriveAction ctx |> Seq.tryHead with Some handler -> handler next ctx | None -> Error.notFound next ctx diff --git a/src/MyWebLog/Maintenance.fs b/src/MyWebLog/Maintenance.fs index ee7d934..8d0f68f 100644 --- a/src/MyWebLog/Maintenance.fs +++ b/src/MyWebLog/Maintenance.fs @@ -23,12 +23,12 @@ let private doCreateWebLog (args : string[]) (sp : IServiceProvider) = task { // Create the web log let webLogId = WebLogId.create () let userId = WebLogUserId.create () - let homePageId = PageId.create () + let homePageId = PageId.Create() let slug = Handlers.Upload.makeSlug args[2] // If this is the first web log being created, the user will be an installation admin; otherwise, they will be an // admin just over their web log - let! webLogs = data.WebLog.All () + let! webLogs = data.WebLog.All() let accessLevel = if List.isEmpty webLogs then Administrator else WebLogAdmin do! data.WebLog.Add @@ -37,7 +37,7 @@ let private doCreateWebLog (args : string[]) (sp : IServiceProvider) = task { Name = args[2] Slug = slug UrlBase = args[1] - DefaultPage = PageId.toString homePageId + DefaultPage = homePageId.Value TimeZone = timeZone } @@ -110,8 +110,8 @@ let private importPriorPermalinks urlBase file (sp : IServiceProvider) = task { 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 -> eprintfn $"Cannot find current post for {Permalink.toString current}" + printfn $"{old.Value} -> {current.Value}" + | None -> eprintfn $"Cannot find current post for {current.Value}" printfn "Done!" | None -> eprintfn $"No web log found at {urlBase}" } @@ -336,8 +336,8 @@ module Backup = 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 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 let newUpIds = archive.Uploads |> List.map (fun up -> up.Id, UploadId.create ()) |> dict return -- 2.45.1 From d8ce59a6cd12356b60f1df8e49c76bd7a182cc6c Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 16 Dec 2023 12:24:45 -0500 Subject: [PATCH 016/123] WIP on module/member conversion Support types done --- src/MyWebLog.Data/Converters.fs | 100 +++--- .../Postgres/PostgresCategoryData.fs | 16 +- src/MyWebLog.Data/Postgres/PostgresHelpers.fs | 4 +- .../Postgres/PostgresPageData.fs | 25 +- .../Postgres/PostgresPostData.fs | 40 +-- .../Postgres/PostgresTagMapData.fs | 10 +- .../Postgres/PostgresThemeData.fs | 26 +- .../Postgres/PostgresUploadData.fs | 8 +- .../Postgres/PostgresWebLogData.fs | 17 +- .../Postgres/PostgresWebLogUserData.fs | 25 +- src/MyWebLog.Data/RethinkDbData.fs | 12 +- src/MyWebLog.Data/SQLite/Helpers.fs | 8 +- .../SQLite/SQLiteCategoryData.fs | 30 +- src/MyWebLog.Data/SQLite/SQLitePageData.fs | 28 +- src/MyWebLog.Data/SQLite/SQLitePostData.fs | 54 +-- src/MyWebLog.Data/SQLite/SQLiteTagMapData.fs | 14 +- src/MyWebLog.Data/SQLite/SQLiteThemeData.fs | 33 +- src/MyWebLog.Data/SQLite/SQLiteUploadData.fs | 16 +- src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs | 30 +- .../SQLite/SQLiteWebLogUserData.fs | 31 +- src/MyWebLog.Data/SQLiteData.fs | 4 +- src/MyWebLog.Data/Utils.fs | 12 +- src/MyWebLog.Domain/DataTypes.fs | 30 +- src/MyWebLog.Domain/SupportTypes.fs | 326 +++++++++--------- src/MyWebLog.Domain/ViewModels.fs | 294 ++++++++-------- src/MyWebLog/Caches.fs | 18 +- src/MyWebLog/DotLiquidBespoke.fs | 6 +- src/MyWebLog/Handlers/Admin.fs | 42 +-- src/MyWebLog/Handlers/Feed.fs | 65 ++-- src/MyWebLog/Handlers/Helpers.fs | 4 +- src/MyWebLog/Handlers/Page.fs | 2 +- src/MyWebLog/Handlers/Post.fs | 12 +- src/MyWebLog/Handlers/Routes.fs | 6 +- src/MyWebLog/Handlers/Upload.fs | 20 +- src/MyWebLog/Handlers/User.fs | 28 +- src/MyWebLog/Maintenance.fs | 28 +- src/MyWebLog/Program.fs | 2 +- 37 files changed, 705 insertions(+), 721 deletions(-) diff --git a/src/MyWebLog.Data/Converters.fs b/src/MyWebLog.Data/Converters.fs index d68025d..b4e07a1 100644 --- a/src/MyWebLog.Data/Converters.fs +++ b/src/MyWebLog.Data/Converters.fs @@ -12,120 +12,120 @@ module Json = type CategoryIdConverter() = inherit JsonConverter() override _.WriteJson(writer: JsonWriter, value: CategoryId, _: JsonSerializer) = - writer.WriteValue value.Value + writer.WriteValue(string 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 value.Value + writer.WriteValue(string value) override _.ReadJson(reader: JsonReader, _: Type, _: CommentId, _: bool, _: JsonSerializer) = (string >> CommentId) reader.Value type CommentStatusConverter() = inherit JsonConverter() override _.WriteJson(writer: JsonWriter, value: CommentStatus, _: JsonSerializer) = - writer.WriteValue value.Value + writer.WriteValue(string value) override _.ReadJson(reader: JsonReader, _: Type, _: CommentStatus, _: bool, _: JsonSerializer) = (string >> CommentStatus.Parse) 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) = + type CustomFeedIdConverter() = + inherit JsonConverter() + override _.WriteJson(writer: JsonWriter, value: CustomFeedId, _: JsonSerializer) = + writer.WriteValue(string 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 CustomFeedSourceConverter() = + inherit JsonConverter() + override _.WriteJson(writer: JsonWriter, value: CustomFeedSource, _: JsonSerializer) = + writer.WriteValue(string 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 value.Value + writer.WriteValue(string 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 value.Value + writer.WriteValue(string 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 value.Value + writer.WriteValue(string 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 value.Value + writer.WriteValue(string value) override _.ReadJson(reader: JsonReader, _: Type, _: PageId, _: bool, _: JsonSerializer) = (string >> PageId) reader.Value type PodcastMediumConverter() = inherit JsonConverter() override _.WriteJson(writer: JsonWriter, value: PodcastMedium, _: JsonSerializer) = - writer.WriteValue value.Value + writer.WriteValue(string value) override _.ReadJson(reader: JsonReader, _: Type, _: PodcastMedium, _: bool, _: JsonSerializer) = (string >> PodcastMedium.Parse) reader.Value type PostIdConverter() = inherit JsonConverter() override _.WriteJson(writer: JsonWriter, value: PostId, _: JsonSerializer) = - writer.WriteValue value.Value + writer.WriteValue(string 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) = + type TagMapIdConverter() = + inherit JsonConverter() + override _.WriteJson(writer: JsonWriter, value: TagMapId, _: JsonSerializer) = + writer.WriteValue(string 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 ThemeAssetIdConverter() = + inherit JsonConverter() + override _.WriteJson(writer: JsonWriter, value: ThemeAssetId, _: JsonSerializer) = + writer.WriteValue(string value) + override _.ReadJson(reader: JsonReader, _: Type, _: ThemeAssetId, _: bool, _: JsonSerializer) = + (string >> ThemeAssetId.Parse) 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) = + type ThemeIdConverter() = + inherit JsonConverter() + override _.WriteJson(writer: JsonWriter, value: ThemeId, _: JsonSerializer) = + writer.WriteValue(string value) + override _.ReadJson(reader: JsonReader, _: Type, _: ThemeId, _: bool, _: JsonSerializer) = (string >> ThemeId) reader.Value - type UploadIdConverter () = - inherit JsonConverter () - override _.WriteJson (writer : JsonWriter, value : UploadId, _ : JsonSerializer) = - writer.WriteValue (UploadId.toString value) - override _.ReadJson (reader : JsonReader, _ : Type, _ : UploadId, _ : bool, _ : JsonSerializer) = + type UploadIdConverter() = + inherit JsonConverter() + override _.WriteJson(writer: JsonWriter, value: UploadId, _: JsonSerializer) = + writer.WriteValue(string value) + override _.ReadJson(reader: JsonReader, _: Type, _: UploadId, _: bool, _: JsonSerializer) = (string >> UploadId) 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) = + type WebLogIdConverter() = + inherit JsonConverter() + override _.WriteJson(writer: JsonWriter, value: WebLogId, _: JsonSerializer) = + writer.WriteValue(string value) + override _.ReadJson(reader: JsonReader, _: Type, _: WebLogId, _: bool, _: JsonSerializer) = (string >> WebLogId) reader.Value - type WebLogUserIdConverter () = + type WebLogUserIdConverter() = inherit JsonConverter () - override _.WriteJson (writer : JsonWriter, value : WebLogUserId, _ : JsonSerializer) = - writer.WriteValue (WebLogUserId.toString value) - override _.ReadJson (reader : JsonReader, _ : Type, _ : WebLogUserId, _ : bool, _ : JsonSerializer) = + override _.WriteJson(writer: JsonWriter, value: WebLogUserId, _: JsonSerializer) = + writer.WriteValue(string value) + override _.ReadJson(reader: JsonReader, _: Type, _: WebLogUserId, _: bool, _: JsonSerializer) = (string >> WebLogUserId) reader.Value open Microsoft.FSharpLu.Json diff --git a/src/MyWebLog.Data/Postgres/PostgresCategoryData.fs b/src/MyWebLog.Data/Postgres/PostgresCategoryData.fs index 08d041c..b78be64 100644 --- a/src/MyWebLog.Data/Postgres/PostgresCategoryData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresCategoryData.fs @@ -43,7 +43,7 @@ type PostgresCategoryData(log: ILogger) = FROM {Table.Post} WHERE {Query.whereDataContains "@criteria"} AND {catIdSql}""" - [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = Published.Value |} + [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = Published |} catIdParams ] Map.toCount |> Async.AwaitTask @@ -64,7 +64,7 @@ type PostgresCategoryData(log: ILogger) = /// Find a category by its ID for the given web log let findById catId webLogId = log.LogTrace "Category.findById" - Document.findByIdAndWebLog Table.Category catId (_.Value) webLogId + Document.findByIdAndWebLog Table.Category catId string webLogId /// Find all categories for the given web log let findByWebLog webLogId = @@ -73,7 +73,7 @@ type PostgresCategoryData(log: ILogger) = /// Create parameters for a category insert / update let catParameters (cat : Category) = - Query.docParameters cat.Id.Value cat + Query.docParameters (string cat.Id) cat /// Delete a category let delete catId webLogId = backgroundTask { @@ -81,7 +81,7 @@ type PostgresCategoryData(log: ILogger) = match! findById catId webLogId with | Some cat -> // Reassign any children to the category's parent category - let! children = Find.byContains Table.Category {| ParentId = catId.Value |} + let! children = Find.byContains Table.Category {| ParentId = string catId |} let hasChildren = not (List.isEmpty children) if hasChildren then let! _ = @@ -90,7 +90,7 @@ type PostgresCategoryData(log: ILogger) = |> Sql.executeTransactionAsync [ Query.Update.partialById Table.Category, children |> List.map (fun child -> [ - "@id", Sql.string child.Id.Value + "@id", Sql.string (string child.Id) "@data", Query.jsonbDocParam {| ParentId = cat.ParentId |} ]) ] @@ -98,7 +98,7 @@ type PostgresCategoryData(log: ILogger) = // Delete the category off all posts where it is assigned let! posts = Custom.list $"SELECT data FROM {Table.Post} WHERE data -> '{nameof Post.empty.CategoryIds}' @> @id" - [ "@id", Query.jsonbDocParam [| catId.Value |] ] fromData + [ "@id", Query.jsonbDocParam [| string catId |] ] fromData if not (List.isEmpty posts) then let! _ = Configuration.dataSource () @@ -106,14 +106,14 @@ type PostgresCategoryData(log: ILogger) = |> Sql.executeTransactionAsync [ Query.Update.partialById Table.Post, posts |> List.map (fun post -> [ - "@id", Sql.string post.Id.Value + "@id", Sql.string (string post.Id) "@data", Query.jsonbDocParam {| CategoryIds = post.CategoryIds |> List.filter (fun cat -> cat <> catId) |} ]) ] () // Delete the category itself - do! Delete.byId Table.Category catId.Value + do! Delete.byId Table.Category (string catId) return if hasChildren then ReassignedChildCategories else CategoryDeleted | None -> return CategoryNotFound } diff --git a/src/MyWebLog.Data/Postgres/PostgresHelpers.fs b/src/MyWebLog.Data/Postgres/PostgresHelpers.fs index b51b1e6..ae73f49 100644 --- a/src/MyWebLog.Data/Postgres/PostgresHelpers.fs +++ b/src/MyWebLog.Data/Postgres/PostgresHelpers.fs @@ -70,7 +70,7 @@ open Npgsql.FSharp /// Create a SQL parameter for the web log ID let webLogIdParam webLogId = - "@webLogId", Sql.string (WebLogId.toString webLogId) + "@webLogId", Sql.string (string webLogId) /// Create an anonymous record with the given web log ID let webLogDoc (webLogId : WebLogId) = @@ -206,7 +206,7 @@ module Revisions = let revParams<'TKey> (key : 'TKey) (keyFunc : 'TKey -> string) rev = [ typedParam "asOf" rev.AsOf "@id", Sql.string (keyFunc key) - "@text", Sql.string rev.Text.Value + "@text", Sql.string (string rev.Text) ] /// The SQL statement to insert a revision diff --git a/src/MyWebLog.Data/Postgres/PostgresPageData.fs b/src/MyWebLog.Data/Postgres/PostgresPageData.fs index 8be3d1b..7bf8c80 100644 --- a/src/MyWebLog.Data/Postgres/PostgresPageData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresPageData.fs @@ -14,7 +14,7 @@ type PostgresPageData (log: ILogger) = /// Append revisions to a page let appendPageRevisions (page: Page) = backgroundTask { log.LogTrace "Page.appendPageRevisions" - let! revisions = Revisions.findByEntityId Table.PageRevision Table.Page page.Id _.Value + let! revisions = Revisions.findByEntityId Table.PageRevision Table.Page page.Id string return { page with Revisions = revisions } } @@ -25,12 +25,12 @@ type PostgresPageData (log: ILogger) = /// Update a page's revisions let updatePageRevisions (pageId: PageId) oldRevs newRevs = log.LogTrace "Page.updatePageRevisions" - Revisions.update Table.PageRevision Table.Page pageId (_.Value) oldRevs newRevs + Revisions.update Table.PageRevision Table.Page pageId string oldRevs newRevs /// Does the given page exist? let pageExists (pageId: PageId) webLogId = log.LogTrace "Page.pageExists" - Document.existsByWebLog Table.Page pageId (_.Value) webLogId + Document.existsByWebLog Table.Page pageId string webLogId // IMPLEMENTATION FUNCTIONS @@ -51,9 +51,9 @@ type PostgresPageData (log: ILogger) = Count.byContains Table.Page {| webLogDoc webLogId with IsInPageList = true |} /// Find a page by its ID (without revisions) - let findById (pageId: PageId) webLogId = + let findById pageId webLogId = log.LogTrace "Page.findById" - Document.findByIdAndWebLog Table.Page pageId (_.Value) webLogId + Document.findByIdAndWebLog Table.Page pageId string webLogId /// Find a complete page by its ID let findFullById pageId webLogId = backgroundTask { @@ -70,7 +70,7 @@ type PostgresPageData (log: ILogger) = log.LogTrace "Page.delete" match! pageExists pageId webLogId with | true -> - do! Delete.byId Table.Page pageId.Value + do! Delete.byId Table.Page (string pageId) return true | false -> return false } @@ -78,16 +78,15 @@ type PostgresPageData (log: ILogger) = /// Find a page by its permalink for the given web log let findByPermalink (permalink: Permalink) webLogId = log.LogTrace "Page.findByPermalink" - Find.byContains Table.Page {| webLogDoc webLogId with Permalink = permalink.Value |} + Find.byContains Table.Page {| webLogDoc webLogId with Permalink = string permalink |} |> tryHead /// Find the current permalink within a set of potential prior permalinks for the given web log - let findCurrentPermalink permalinks webLogId = backgroundTask { + let findCurrentPermalink (permalinks: Permalink list) webLogId = backgroundTask { log.LogTrace "Page.findCurrentPermalink" if List.isEmpty permalinks then return None else - let linkSql, linkParam = - arrayContains (nameof Page.empty.PriorPermalinks) (fun (it: Permalink) -> it.Value) permalinks + let linkSql, linkParam = arrayContains (nameof Page.empty.PriorPermalinks) string permalinks return! Custom.single $"""SELECT data ->> '{nameof Page.empty.Permalink}' AS permalink @@ -134,9 +133,9 @@ type PostgresPageData (log: ILogger) = |> Sql.executeTransactionAsync [ Query.insert Table.Page, pages - |> List.map (fun page -> Query.docParameters page.Id.Value { page with Revisions = [] }) + |> List.map (fun page -> Query.docParameters (string page.Id) { page with Revisions = [] }) Revisions.insertSql Table.PageRevision, - revisions |> List.map (fun (pageId, rev) -> Revisions.revParams pageId (_.Value) rev) + revisions |> List.map (fun (pageId, rev) -> Revisions.revParams pageId string rev) ] () } @@ -155,7 +154,7 @@ type PostgresPageData (log: ILogger) = log.LogTrace "Page.updatePriorPermalinks" match! pageExists pageId webLogId with | true -> - do! Update.partialById Table.Page pageId.Value {| PriorPermalinks = permalinks |} + do! Update.partialById Table.Page (string pageId) {| PriorPermalinks = permalinks |} return true | false -> return false } diff --git a/src/MyWebLog.Data/Postgres/PostgresPostData.fs b/src/MyWebLog.Data/Postgres/PostgresPostData.fs index c1f6248..7984d35 100644 --- a/src/MyWebLog.Data/Postgres/PostgresPostData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresPostData.fs @@ -15,7 +15,7 @@ type PostgresPostData(log: ILogger) = /// Append revisions to a post let appendPostRevisions (post: Post) = backgroundTask { log.LogTrace "Post.appendPostRevisions" - let! revisions = Revisions.findByEntityId Table.PostRevision Table.Post post.Id _.Value + let! revisions = Revisions.findByEntityId Table.PostRevision Table.Post post.Id string return { post with Revisions = revisions } } @@ -26,30 +26,30 @@ type PostgresPostData(log: ILogger) = /// Update a post's revisions let updatePostRevisions (postId: PostId) oldRevs newRevs = log.LogTrace "Post.updatePostRevisions" - Revisions.update Table.PostRevision Table.Post postId (_.Value) oldRevs newRevs + Revisions.update Table.PostRevision Table.Post postId string oldRevs newRevs /// Does the given post exist? let postExists (postId: PostId) webLogId = log.LogTrace "Post.postExists" - Document.existsByWebLog Table.Post postId (_.Value) webLogId + Document.existsByWebLog Table.Post postId string webLogId // IMPLEMENTATION FUNCTIONS /// Count posts in a status for the given web log let countByStatus (status: PostStatus) webLogId = log.LogTrace "Post.countByStatus" - Count.byContains Table.Post {| webLogDoc webLogId with Status = status.Value |} + Count.byContains Table.Post {| webLogDoc webLogId with Status = status |} /// Find a post by its ID for the given web log (excluding revisions) let findById postId webLogId = log.LogTrace "Post.findById" - Document.findByIdAndWebLog Table.Post postId (_.Value) webLogId + Document.findByIdAndWebLog Table.Post postId string webLogId /// Find a post by its permalink for the given web log (excluding revisions and prior permalinks) let findByPermalink (permalink: Permalink) webLogId = log.LogTrace "Post.findByPermalink" Custom.single (selectWithCriteria Table.Post) - [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Permalink = permalink.Value |} ] + [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Permalink = string permalink |} ] fromData /// Find a complete post by its ID for the given web log @@ -70,18 +70,17 @@ type PostgresPostData(log: ILogger) = do! Custom.nonQuery $"""DELETE FROM {Table.PostComment} WHERE {Query.whereDataContains "@criteria"}; DELETE FROM {Table.Post} WHERE id = @id""" - [ "@id", Sql.string postId.Value; "@criteria", Query.jsonbDocParam {| PostId = postId.Value |} ] + [ "@id", Sql.string (string postId); "@criteria", Query.jsonbDocParam {| PostId = postId |} ] return true | false -> return false } /// Find the current permalink from a list of potential prior permalinks for the given web log - let findCurrentPermalink permalinks webLogId = backgroundTask { + let findCurrentPermalink (permalinks: Permalink list) webLogId = backgroundTask { log.LogTrace "Post.findCurrentPermalink" if List.isEmpty permalinks then return None else - let linkSql, linkParam = - arrayContains (nameof Post.empty.PriorPermalinks) (fun (it: Permalink) -> it.Value) permalinks + let linkSql, linkParam = arrayContains (nameof Post.empty.PriorPermalinks) string permalinks return! Custom.single $"""SELECT data ->> '{nameof Post.empty.Permalink}' AS permalink @@ -102,16 +101,15 @@ type PostgresPostData(log: ILogger) = } /// Get a page of categorized posts for the given web log (excludes revisions) - let findPageOfCategorizedPosts webLogId categoryIds pageNbr postsPerPage = + let findPageOfCategorizedPosts webLogId (categoryIds: CategoryId list) pageNbr postsPerPage = log.LogTrace "Post.findPageOfCategorizedPosts" - let catSql, catParam = - arrayContains (nameof Post.empty.CategoryIds) (fun (it: CategoryId) -> it.Value) categoryIds + let catSql, catParam = arrayContains (nameof Post.empty.CategoryIds) string categoryIds Custom.list $"{selectWithCriteria Table.Post} AND {catSql} ORDER BY data ->> '{nameof Post.empty.PublishedOn}' DESC LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}" - [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = Published.Value |} + [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = Published |} catParam ] fromData @@ -132,7 +130,7 @@ type PostgresPostData(log: ILogger) = $"{selectWithCriteria Table.Post} ORDER BY data ->> '{nameof Post.empty.PublishedOn}' DESC LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}" - [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = Published.Value |} ] + [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = Published |} ] fromData /// Get a page of tagged posts for the given web log (excludes revisions and prior permalinks) @@ -143,7 +141,7 @@ type PostgresPostData(log: ILogger) = AND data['{nameof Post.empty.Tags}'] @> @tag ORDER BY data ->> '{nameof Post.empty.PublishedOn}' DESC LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}" - [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = Published.Value |} + [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = Published |} "@tag", Query.jsonbDocParam [| tag |] ] fromData @@ -151,8 +149,8 @@ type PostgresPostData(log: ILogger) = let findSurroundingPosts webLogId publishedOn = backgroundTask { log.LogTrace "Post.findSurroundingPosts" let queryParams () = [ - "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = Published.Value |} - "@publishedOn", Sql.string ((InstantPattern.General.Format publishedOn).Substring (0, 19)) + "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = Published |} + "@publishedOn", Sql.string ((InstantPattern.General.Format publishedOn)[..19]) ] let pubField = nameof Post.empty.PublishedOn let! older = @@ -187,9 +185,9 @@ type PostgresPostData(log: ILogger) = |> Sql.fromDataSource |> Sql.executeTransactionAsync [ Query.insert Table.Post, - posts |> List.map (fun post -> Query.docParameters post.Id.Value { post with Revisions = [] }) + posts |> List.map (fun post -> Query.docParameters (string post.Id) { post with Revisions = [] }) Revisions.insertSql Table.PostRevision, - revisions |> List.map (fun (postId, rev) -> Revisions.revParams postId (_.Value) rev) + revisions |> List.map (fun (postId, rev) -> Revisions.revParams postId string rev) ] () } @@ -199,7 +197,7 @@ type PostgresPostData(log: ILogger) = log.LogTrace "Post.updatePriorPermalinks" match! postExists postId webLogId with | true -> - do! Update.partialById Table.Post postId.Value {| PriorPermalinks = permalinks |} + do! Update.partialById Table.Post (string postId) {| PriorPermalinks = permalinks |} return true | false -> return false } diff --git a/src/MyWebLog.Data/Postgres/PostgresTagMapData.fs b/src/MyWebLog.Data/Postgres/PostgresTagMapData.fs index 210dc14..100523a 100644 --- a/src/MyWebLog.Data/Postgres/PostgresTagMapData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresTagMapData.fs @@ -12,14 +12,14 @@ type PostgresTagMapData (log : ILogger) = /// Find a tag mapping by its ID for the given web log let findById tagMapId webLogId = log.LogTrace "TagMap.findById" - Document.findByIdAndWebLog Table.TagMap tagMapId TagMapId.toString webLogId + Document.findByIdAndWebLog Table.TagMap tagMapId string webLogId /// Delete a tag mapping for the given web log - let delete tagMapId webLogId = backgroundTask { + let delete (tagMapId: TagMapId) webLogId = backgroundTask { log.LogTrace "TagMap.delete" - let! exists = Document.existsByWebLog Table.TagMap tagMapId TagMapId.toString webLogId + let! exists = Document.existsByWebLog Table.TagMap tagMapId string webLogId if exists then - do! Delete.byId Table.TagMap (TagMapId.toString tagMapId) + do! Delete.byId Table.TagMap (string tagMapId) return true else return false } @@ -55,7 +55,7 @@ type PostgresTagMapData (log : ILogger) = |> Sql.fromDataSource |> Sql.executeTransactionAsync [ Query.insert Table.TagMap, - tagMaps |> List.map (fun tagMap -> Query.docParameters (TagMapId.toString tagMap.Id) tagMap) + tagMaps |> List.map (fun tagMap -> Query.docParameters (string tagMap.Id) tagMap) ] () } diff --git a/src/MyWebLog.Data/Postgres/PostgresThemeData.fs b/src/MyWebLog.Data/Postgres/PostgresThemeData.fs index cf3a569..ed7e4bb 100644 --- a/src/MyWebLog.Data/Postgres/PostgresThemeData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresThemeData.fs @@ -20,26 +20,26 @@ type PostgresThemeData (log : ILogger) = Custom.list $"{Query.selectFromTable Table.Theme} WHERE id <> 'admin' ORDER BY id" [] withoutTemplateText /// Does a given theme exist? - let exists themeId = + let exists (themeId: ThemeId) = log.LogTrace "Theme.exists" - Exists.byId Table.Theme (ThemeId.toString themeId) + Exists.byId Table.Theme (string themeId) /// Find a theme by its ID - let findById themeId = + let findById (themeId: ThemeId) = log.LogTrace "Theme.findById" - Find.byId Table.Theme (ThemeId.toString themeId) + Find.byId Table.Theme (string themeId) /// Find a theme by its ID (excludes the text of templates) - let findByIdWithoutText themeId = + let findByIdWithoutText (themeId: ThemeId) = log.LogTrace "Theme.findByIdWithoutText" - Custom.single (Query.Find.byId Table.Theme) [ "@id", Sql.string (ThemeId.toString themeId) ] withoutTemplateText + Custom.single (Query.Find.byId Table.Theme) [ "@id", Sql.string (string themeId) ] withoutTemplateText /// Delete a theme by its ID let delete themeId = backgroundTask { log.LogTrace "Theme.delete" match! exists themeId with | true -> - do! Delete.byId Table.Theme (ThemeId.toString themeId) + do! Delete.byId Table.Theme (string themeId) return true | false -> return false } @@ -67,10 +67,10 @@ type PostgresThemeAssetData (log : ILogger) = Custom.list $"SELECT theme_id, path, updated_on FROM {Table.ThemeAsset}" [] (Map.toThemeAsset false) /// Delete all assets for the given theme - let deleteByTheme themeId = + let deleteByTheme (themeId: ThemeId) = log.LogTrace "ThemeAsset.deleteByTheme" Custom.nonQuery $"DELETE FROM {Table.ThemeAsset} WHERE theme_id = @themeId" - [ "@themeId", Sql.string (ThemeId.toString themeId) ] + [ "@themeId", Sql.string (string themeId) ] /// Find a theme asset by its ID let findById assetId = @@ -80,16 +80,16 @@ type PostgresThemeAssetData (log : ILogger) = [ "@themeId", Sql.string themeId; "@path", Sql.string path ] (Map.toThemeAsset true) /// Get theme assets for the given theme (excludes data) - let findByTheme themeId = + let findByTheme (themeId: ThemeId) = log.LogTrace "ThemeAsset.findByTheme" Custom.list $"SELECT theme_id, path, updated_on FROM {Table.ThemeAsset} WHERE theme_id = @themeId" - [ "@themeId", Sql.string (ThemeId.toString themeId) ] (Map.toThemeAsset false) + [ "@themeId", Sql.string (string themeId) ] (Map.toThemeAsset false) /// Get theme assets for the given theme - let findByThemeWithData themeId = + let findByThemeWithData (themeId: ThemeId) = log.LogTrace "ThemeAsset.findByThemeWithData" Custom.list $"SELECT * FROM {Table.ThemeAsset} WHERE theme_id = @themeId" - [ "@themeId", Sql.string (ThemeId.toString themeId) ] (Map.toThemeAsset true) + [ "@themeId", Sql.string (string themeId) ] (Map.toThemeAsset true) /// Save a theme asset let save (asset : ThemeAsset) = diff --git a/src/MyWebLog.Data/Postgres/PostgresUploadData.fs b/src/MyWebLog.Data/Postgres/PostgresUploadData.fs index 136da11..e97b212 100644 --- a/src/MyWebLog.Data/Postgres/PostgresUploadData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresUploadData.fs @@ -21,8 +21,8 @@ type PostgresUploadData (log : ILogger) = let upParams (upload : Upload) = [ webLogIdParam upload.WebLogId typedParam "updatedOn" upload.UpdatedOn - "@id", Sql.string (UploadId.toString upload.Id) - "@path", Sql.string upload.Path.Value + "@id", Sql.string (string upload.Id) + "@path", Sql.string (string upload.Path) "@data", Sql.bytea upload.Data ] @@ -34,14 +34,14 @@ type PostgresUploadData (log : ILogger) = /// Delete an uploaded file by its ID let delete uploadId webLogId = backgroundTask { log.LogTrace "Upload.delete" - let idParam = [ "@id", Sql.string (UploadId.toString uploadId) ] + let idParam = [ "@id", Sql.string (string uploadId) ] let! path = Custom.single $"SELECT path FROM {Table.Upload} WHERE id = @id AND web_log_id = @webLogId" (webLogIdParam webLogId :: idParam) (fun row -> row.string "path") if Option.isSome path then do! Custom.nonQuery (Query.Delete.byId Table.Upload) idParam return Ok path.Value - else return Error $"""Upload ID {UploadId.toString uploadId} not found""" + else return Error $"""Upload ID {uploadId} not found""" } /// Find an uploaded file by its path for the given web log diff --git a/src/MyWebLog.Data/Postgres/PostgresWebLogData.fs b/src/MyWebLog.Data/Postgres/PostgresWebLogData.fs index f583cc2..0efc85d 100644 --- a/src/MyWebLog.Data/Postgres/PostgresWebLogData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresWebLogData.fs @@ -41,29 +41,30 @@ type PostgresWebLogData (log : ILogger) = fromData /// Find a web log by its ID - let findById webLogId = + let findById (webLogId: WebLogId) = log.LogTrace "WebLog.findById" - Find.byId Table.WebLog (WebLogId.toString webLogId) + Find.byId Table.WebLog (string webLogId) - let updateRedirectRules (webLog : WebLog) = backgroundTask { + let updateRedirectRules (webLog: WebLog) = backgroundTask { log.LogTrace "WebLog.updateRedirectRules" match! findById webLog.Id with | Some _ -> - do! Update.partialById Table.WebLog (WebLogId.toString webLog.Id) {| RedirectRules = webLog.RedirectRules |} + do! Update.partialById Table.WebLog (string webLog.Id) {| RedirectRules = webLog.RedirectRules |} | None -> () } + /// Update RSS options for a web log - let updateRssOptions (webLog : WebLog) = backgroundTask { + let updateRssOptions (webLog: WebLog) = backgroundTask { log.LogTrace "WebLog.updateRssOptions" match! findById webLog.Id with - | Some _ -> do! Update.partialById Table.WebLog (WebLogId.toString webLog.Id) {| Rss = webLog.Rss |} + | Some _ -> do! Update.partialById Table.WebLog (string webLog.Id) {| Rss = webLog.Rss |} | None -> () } /// Update settings for a web log - let updateSettings (webLog : WebLog) = + let updateSettings (webLog: WebLog) = log.LogTrace "WebLog.updateSettings" - Update.full Table.WebLog (WebLogId.toString webLog.Id) webLog + Update.full Table.WebLog (string webLog.Id) webLog interface IWebLogData with member _.Add webLog = add webLog diff --git a/src/MyWebLog.Data/Postgres/PostgresWebLogUserData.fs b/src/MyWebLog.Data/Postgres/PostgresWebLogUserData.fs index 32b912e..dba0985 100644 --- a/src/MyWebLog.Data/Postgres/PostgresWebLogUserData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresWebLogUserData.fs @@ -12,7 +12,7 @@ type PostgresWebLogUserData (log : ILogger) = /// Find a user by their ID for the given web log let findById userId webLogId = log.LogTrace "WebLogUser.findById" - Document.findByIdAndWebLog Table.WebLogUser userId WebLogUserId.toString webLogId + Document.findByIdAndWebLog Table.WebLogUser userId string webLogId /// Delete a user if they have no posts or pages let delete userId webLogId = backgroundTask { @@ -29,7 +29,7 @@ type PostgresWebLogUserData (log : ILogger) = if isAuthor then return Error "User has pages or posts; cannot delete" else - do! Delete.byId Table.WebLogUser (WebLogUserId.toString userId) + do! Delete.byId Table.WebLogUser (string userId) return Ok true | None -> return Error "User does not exist" } @@ -49,41 +49,38 @@ type PostgresWebLogUserData (log : ILogger) = [ webLogContains webLogId ] fromData /// Find the names of users by their IDs for the given web log - let findNames webLogId userIds = backgroundTask { + let findNames webLogId (userIds: WebLogUserId list) = backgroundTask { log.LogTrace "WebLogUser.findNames" - let idSql, idParams = inClause "AND id" "id" WebLogUserId.toString userIds + let idSql, idParams = inClause "AND id" "id" string userIds let! users = Custom.list $"{selectWithCriteria Table.WebLogUser} {idSql}" (webLogContains webLogId :: idParams) fromData - return - users - |> List.map (fun u -> { Name = WebLogUserId.toString u.Id; Value = WebLogUser.displayName u }) + return users |> List.map (fun u -> { Name = string u.Id; Value = WebLogUser.displayName u }) } /// Restore users from a backup - let restore (users : WebLogUser list) = backgroundTask { + let restore (users: WebLogUser list) = backgroundTask { log.LogTrace "WebLogUser.restore" let! _ = Configuration.dataSource () |> Sql.fromDataSource |> Sql.executeTransactionAsync [ Query.insert Table.WebLogUser, - users |> List.map (fun user -> Query.docParameters (WebLogUserId.toString user.Id) user) + users |> List.map (fun user -> Query.docParameters (string user.Id) user) ] () } /// Set a user's last seen date/time to now - let setLastSeen userId webLogId = backgroundTask { + let setLastSeen (userId: WebLogUserId) webLogId = backgroundTask { log.LogTrace "WebLogUser.setLastSeen" - match! Document.existsByWebLog Table.WebLogUser userId WebLogUserId.toString webLogId with - | true -> - do! Update.partialById Table.WebLogUser (WebLogUserId.toString userId) {| LastSeenOn = Some (Noda.now ()) |} + match! Document.existsByWebLog Table.WebLogUser userId string webLogId with + | true -> do! Update.partialById Table.WebLogUser (string userId) {| LastSeenOn = Some (Noda.now ()) |} | false -> () } /// Save a user - let save (user : WebLogUser) = + let save (user: WebLogUser) = log.LogTrace "WebLogUser.save" save Table.WebLogUser user diff --git a/src/MyWebLog.Data/RethinkDbData.fs b/src/MyWebLog.Data/RethinkDbData.fs index e653b1f..acda3a6 100644 --- a/src/MyWebLog.Data/RethinkDbData.fs +++ b/src/MyWebLog.Data/RethinkDbData.fs @@ -96,12 +96,12 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger row[nameof ThemeAsset.empty.Id].Match keyPrefix :> obj /// Function to exclude template text from themes let withoutTemplateText (row : Ast.ReqlExpr) : obj = - {| Templates = row[nameof Theme.empty.Templates].Without [| nameof ThemeTemplate.empty.Text |] |} + {| Templates = row[nameof Theme.empty.Templates].Without [| nameof ThemeTemplate.Empty.Text |] |} /// Ensure field indexes exist, as well as special indexes for selected tables let ensureIndexes table fields = backgroundTask { @@ -917,8 +917,8 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger return Result.Error $"Upload ID {UploadId.toString uploadId} not found" + return Ok (string up.Path) + | None -> return Result.Error $"Upload ID {uploadId} not found" } member _.FindByPath path webLogId = @@ -1133,9 +1133,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger List.map (fun u -> { Name = WebLogUserId.toString u.Id; Value = WebLogUser.displayName u }) + return users |> List.map (fun u -> { Name = string u.Id; Value = WebLogUser.displayName u }) } member _.Restore users = backgroundTask { diff --git a/src/MyWebLog.Data/SQLite/Helpers.fs b/src/MyWebLog.Data/SQLite/Helpers.fs index cdd31a2..b37c7bc 100644 --- a/src/MyWebLog.Data/SQLite/Helpers.fs +++ b/src/MyWebLog.Data/SQLite/Helpers.fs @@ -222,7 +222,7 @@ module Map = /// Create a custom feed from the current row in the given data reader let toCustomFeed ser rdr : CustomFeed = { Id = getString "id" rdr |> CustomFeedId - Source = getString "source" rdr |> CustomFeedSource.parse + Source = getString "source" rdr |> CustomFeedSource.Parse Path = getString "path" rdr |> Permalink Podcast = tryString "podcast" rdr |> Option.map (Utils.deserialize ser) } @@ -339,7 +339,7 @@ module Map = UrlBase = getString "url_base" rdr TimeZone = getString "time_zone" rdr AutoHtmx = getBoolean "auto_htmx" rdr - Uploads = getString "uploads" rdr |> UploadDestination.parse + Uploads = getString "uploads" rdr |> UploadDestination.Parse Rss = { IsFeedEnabled = getBoolean "is_feed_enabled" rdr FeedName = getString "feed_name" rdr @@ -368,5 +368,5 @@ module Map = } /// Add a web log ID parameter -let addWebLogId (cmd : SqliteCommand) webLogId = - cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString webLogId) |> ignore +let addWebLogId (cmd: SqliteCommand) (webLogId: WebLogId) = + cmd.Parameters.AddWithValue ("@webLogId", string webLogId) |> ignore diff --git a/src/MyWebLog.Data/SQLite/SQLiteCategoryData.fs b/src/MyWebLog.Data/SQLite/SQLiteCategoryData.fs index 3caae20..d3d96b2 100644 --- a/src/MyWebLog.Data/SQLite/SQLiteCategoryData.fs +++ b/src/MyWebLog.Data/SQLite/SQLiteCategoryData.fs @@ -5,17 +5,17 @@ open Microsoft.Data.Sqlite open MyWebLog open MyWebLog.Data -/// SQLite myWebLog category data implementation -type SQLiteCategoryData (conn : SqliteConnection) = +/// SQLite myWebLog category data implementation +type SQLiteCategoryData(conn: SqliteConnection) = /// Add parameters for category INSERT or UPDATE statements - let addCategoryParameters (cmd : SqliteCommand) (cat : Category) = - [ cmd.Parameters.AddWithValue ("@id", cat.Id.Value) - cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString cat.WebLogId) + let addCategoryParameters (cmd: SqliteCommand) (cat: Category) = + [ cmd.Parameters.AddWithValue ("@id", string cat.Id) + cmd.Parameters.AddWithValue ("@webLogId", string 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 _.Value)) + cmd.Parameters.AddWithValue ("@parentId", maybe (cat.ParentId |> Option.map string)) ] |> ignore /// Add a category @@ -102,18 +102,18 @@ type SQLiteCategoryData (conn : SqliteConnection) = } /// Find a category by its ID for the given web log let findById (catId: CategoryId) webLogId = backgroundTask { - use cmd = conn.CreateCommand () + use cmd = conn.CreateCommand() cmd.CommandText <- "SELECT * FROM category WHERE id = @id" - cmd.Parameters.AddWithValue ("@id", catId.Value) |> ignore - use! rdr = cmd.ExecuteReaderAsync () - return Helpers.verifyWebLog webLogId (fun c -> c.WebLogId) Map.toCategory rdr + cmd.Parameters.AddWithValue ("@id", string catId) |> ignore + use! rdr = cmd.ExecuteReaderAsync() + return verifyWebLog webLogId (_.WebLogId) Map.toCategory rdr } /// Find all categories for the given web log - let findByWebLog webLogId = backgroundTask { + let findByWebLog (webLogId: WebLogId) = backgroundTask { use cmd = conn.CreateCommand () cmd.CommandText <- "SELECT * FROM category WHERE web_log_id = @webLogId" - cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString webLogId) |> ignore + cmd.Parameters.AddWithValue ("@webLogId", string webLogId) |> ignore use! rdr = cmd.ExecuteReaderAsync () return toList Map.toCategory rdr } @@ -125,11 +125,11 @@ type SQLiteCategoryData (conn : SqliteConnection) = use cmd = conn.CreateCommand () // Reassign any children to the category's parent category cmd.CommandText <- "SELECT COUNT(id) FROM category WHERE parent_id = @parentId" - cmd.Parameters.AddWithValue ("@parentId", catId.Value) |> ignore + cmd.Parameters.AddWithValue ("@parentId", string catId) |> ignore let! children = count cmd if children > 0 then cmd.CommandText <- "UPDATE category SET parent_id = @newParentId WHERE parent_id = @parentId" - cmd.Parameters.AddWithValue ("@newParentId", maybe (cat.ParentId |> Option.map _.Value)) + cmd.Parameters.AddWithValue ("@newParentId", maybe (cat.ParentId |> Option.map string)) |> ignore do! write cmd // Delete the category off all posts where it is assigned, and the category itself @@ -139,7 +139,7 @@ type SQLiteCategoryData (conn : SqliteConnection) = AND post_id IN (SELECT id FROM post WHERE web_log_id = @webLogId); DELETE FROM category WHERE id = @id" cmd.Parameters.Clear () - let _ = cmd.Parameters.AddWithValue ("@id", catId.Value) + let _ = cmd.Parameters.AddWithValue ("@id", string catId) addWebLogId cmd webLogId do! write cmd return if children = 0 then CategoryDeleted else ReassignedChildCategories diff --git a/src/MyWebLog.Data/SQLite/SQLitePageData.fs b/src/MyWebLog.Data/SQLite/SQLitePageData.fs index c3ae850..c8ebd49 100644 --- a/src/MyWebLog.Data/SQLite/SQLitePageData.fs +++ b/src/MyWebLog.Data/SQLite/SQLitePageData.fs @@ -13,11 +13,11 @@ type SQLitePageData(conn: SqliteConnection, ser: JsonSerializer) = /// Add parameters for page INSERT or UPDATE statements let addPageParameters (cmd: SqliteCommand) (page: Page) = - [ cmd.Parameters.AddWithValue ("@id", page.Id.Value) - cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString page.WebLogId) - cmd.Parameters.AddWithValue ("@authorId", WebLogUserId.toString page.AuthorId) + [ cmd.Parameters.AddWithValue ("@id", string page.Id) + cmd.Parameters.AddWithValue ("@webLogId", string page.WebLogId) + cmd.Parameters.AddWithValue ("@authorId", string page.AuthorId) cmd.Parameters.AddWithValue ("@title", page.Title) - cmd.Parameters.AddWithValue ("@permalink", page.Permalink.Value) + cmd.Parameters.AddWithValue ("@permalink", string page.Permalink) cmd.Parameters.AddWithValue ("@publishedOn", instantParam page.PublishedOn) cmd.Parameters.AddWithValue ("@updatedOn", instantParam page.UpdatedOn) cmd.Parameters.AddWithValue ("@isInPageList", page.IsInPageList) @@ -30,7 +30,7 @@ type SQLitePageData(conn: SqliteConnection, ser: JsonSerializer) = /// Append revisions and permalinks to a page let appendPageRevisionsAndPermalinks (page : Page) = backgroundTask { use cmd = conn.CreateCommand () - cmd.Parameters.AddWithValue ("@pageId", page.Id.Value) |> ignore + cmd.Parameters.AddWithValue ("@pageId", string page.Id) |> ignore cmd.CommandText <- "SELECT permalink FROM page_permalink WHERE page_id = @pageId" use! rdr = cmd.ExecuteReaderAsync () @@ -57,11 +57,11 @@ type SQLitePageData(conn: SqliteConnection, ser: JsonSerializer) = return () else use cmd = conn.CreateCommand () - [ cmd.Parameters.AddWithValue ("@pageId", pageId.Value) + [ cmd.Parameters.AddWithValue ("@pageId", string pageId) cmd.Parameters.Add ("@link", SqliteType.Text) ] |> ignore let runCmd (link: Permalink) = backgroundTask { - cmd.Parameters["@link"].Value <- link.Value + cmd.Parameters["@link"].Value <- string link do! write cmd } cmd.CommandText <- "DELETE FROM page_permalink WHERE page_id = @pageId AND permalink = @link" @@ -85,10 +85,10 @@ type SQLitePageData(conn: SqliteConnection, ser: JsonSerializer) = use cmd = conn.CreateCommand () let runCmd withText rev = backgroundTask { cmd.Parameters.Clear () - [ cmd.Parameters.AddWithValue ("@pageId", pageId.Value) + [ cmd.Parameters.AddWithValue ("@pageId", string pageId) cmd.Parameters.AddWithValue ("@asOf", instantParam rev.AsOf) ] |> ignore - if withText then cmd.Parameters.AddWithValue ("@text", rev.Text.Value) |> ignore + if withText then cmd.Parameters.AddWithValue ("@text", string rev.Text) |> ignore do! write cmd } cmd.CommandText <- "DELETE FROM page_revision WHERE page_id = @pageId AND as_of = @asOf" @@ -157,7 +157,7 @@ type SQLitePageData(conn: SqliteConnection, ser: JsonSerializer) = let findById (pageId: PageId) webLogId = backgroundTask { use cmd = conn.CreateCommand () cmd.CommandText <- "SELECT * FROM page WHERE id = @id" - cmd.Parameters.AddWithValue ("@id", pageId.Value) |> ignore + cmd.Parameters.AddWithValue ("@id", string pageId) |> ignore use! rdr = cmd.ExecuteReaderAsync () return verifyWebLog webLogId (_.WebLogId) (Map.toPage ser) rdr } @@ -175,7 +175,7 @@ type SQLitePageData(conn: SqliteConnection, ser: JsonSerializer) = match! findById pageId webLogId with | Some _ -> use cmd = conn.CreateCommand () - cmd.Parameters.AddWithValue ("@id", pageId.Value) |> ignore + cmd.Parameters.AddWithValue ("@id", string pageId) |> ignore cmd.CommandText <- "DELETE FROM page_revision WHERE page_id = @id; DELETE FROM page_permalink WHERE page_id = @id; @@ -190,15 +190,15 @@ type SQLitePageData(conn: SqliteConnection, ser: JsonSerializer) = 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.Value) |> ignore + cmd.Parameters.AddWithValue ("@link", string permalink) |> ignore use! rdr = cmd.ExecuteReaderAsync () return if rdr.Read () then Some (toPage rdr) else None } /// Find the current permalink within a set of potential prior permalinks for the given web log - let findCurrentPermalink permalinks webLogId = backgroundTask { + let findCurrentPermalink (permalinks: Permalink list) webLogId = backgroundTask { use cmd = conn.CreateCommand () - let linkSql, linkParams = inClause "AND pp.permalink" "link" (fun (it: Permalink) -> it.Value) permalinks + let linkSql, linkParams = inClause "AND pp.permalink" "link" string permalinks cmd.CommandText <- $" SELECT p.permalink FROM page p diff --git a/src/MyWebLog.Data/SQLite/SQLitePostData.fs b/src/MyWebLog.Data/SQLite/SQLitePostData.fs index c12ecab..97c02b4 100644 --- a/src/MyWebLog.Data/SQLite/SQLitePostData.fs +++ b/src/MyWebLog.Data/SQLite/SQLitePostData.fs @@ -14,12 +14,12 @@ type SQLitePostData(conn: SqliteConnection, ser: JsonSerializer) = /// Add parameters for post INSERT or UPDATE statements let addPostParameters (cmd: SqliteCommand) (post: Post) = - [ cmd.Parameters.AddWithValue ("@id", post.Id.Value) - cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString post.WebLogId) - cmd.Parameters.AddWithValue ("@authorId", WebLogUserId.toString post.AuthorId) - cmd.Parameters.AddWithValue ("@status", post.Status.Value) + [ cmd.Parameters.AddWithValue ("@id", string post.Id) + cmd.Parameters.AddWithValue ("@webLogId", string post.WebLogId) + cmd.Parameters.AddWithValue ("@authorId", string post.AuthorId) + cmd.Parameters.AddWithValue ("@status", string post.Status) cmd.Parameters.AddWithValue ("@title", post.Title) - cmd.Parameters.AddWithValue ("@permalink", post.Permalink.Value) + cmd.Parameters.AddWithValue ("@permalink", string post.Permalink) cmd.Parameters.AddWithValue ("@publishedOn", maybeInstant post.PublishedOn) cmd.Parameters.AddWithValue ("@updatedOn", instantParam post.UpdatedOn) cmd.Parameters.AddWithValue ("@template", maybe post.Template) @@ -34,7 +34,7 @@ type SQLitePostData(conn: SqliteConnection, ser: JsonSerializer) = /// Append category IDs and tags to a post let appendPostCategoryAndTag (post: Post) = backgroundTask { use cmd = conn.CreateCommand () - cmd.Parameters.AddWithValue ("@id", post.Id.Value) |> ignore + cmd.Parameters.AddWithValue ("@id", string post.Id) |> ignore cmd.CommandText <- "SELECT category_id AS id FROM post_category WHERE post_id = @id" use! rdr = cmd.ExecuteReaderAsync () @@ -49,7 +49,7 @@ type SQLitePostData(conn: SqliteConnection, ser: JsonSerializer) = /// Append revisions and permalinks to a post let appendPostRevisionsAndPermalinks (post: Post) = backgroundTask { use cmd = conn.CreateCommand () - cmd.Parameters.AddWithValue ("@postId", post.Id.Value) |> ignore + cmd.Parameters.AddWithValue ("@postId", string post.Id) |> ignore cmd.CommandText <- "SELECT permalink FROM post_permalink WHERE post_id = @postId" use! rdr = cmd.ExecuteReaderAsync () @@ -72,7 +72,7 @@ type SQLitePostData(conn: SqliteConnection, ser: JsonSerializer) = let findPostById (postId: PostId) webLogId = backgroundTask { use cmd = conn.CreateCommand () cmd.CommandText <- $"{selectPost} WHERE p.id = @id" - cmd.Parameters.AddWithValue ("@id", postId.Value) |> ignore + cmd.Parameters.AddWithValue ("@id", string postId) |> ignore use! rdr = cmd.ExecuteReaderAsync () return verifyWebLog webLogId (_.WebLogId) toPost rdr } @@ -83,16 +83,16 @@ type SQLitePostData(conn: SqliteConnection, ser: JsonSerializer) = /// Update a post's assigned categories let updatePostCategories (postId: PostId) oldCats newCats = backgroundTask { - let toDelete, toAdd = Utils.diffLists oldCats newCats _.Value + let toDelete, toAdd = Utils.diffLists oldCats newCats string if List.isEmpty toDelete && List.isEmpty toAdd then return () else use cmd = conn.CreateCommand () - [ cmd.Parameters.AddWithValue ("@postId", postId.Value) + [ cmd.Parameters.AddWithValue ("@postId", string postId) cmd.Parameters.Add ("@categoryId", SqliteType.Text) ] |> ignore let runCmd (catId: CategoryId) = backgroundTask { - cmd.Parameters["@categoryId"].Value <- catId.Value + cmd.Parameters["@categoryId"].Value <- string catId do! write cmd } cmd.CommandText <- "DELETE FROM post_category WHERE post_id = @postId AND category_id = @categoryId" @@ -114,7 +114,7 @@ type SQLitePostData(conn: SqliteConnection, ser: JsonSerializer) = return () else use cmd = conn.CreateCommand () - [ cmd.Parameters.AddWithValue ("@postId", postId.Value) + [ cmd.Parameters.AddWithValue ("@postId", string postId) cmd.Parameters.Add ("@tag", SqliteType.Text) ] |> ignore let runCmd (tag: string) = backgroundTask { @@ -140,11 +140,11 @@ type SQLitePostData(conn: SqliteConnection, ser: JsonSerializer) = return () else use cmd = conn.CreateCommand () - [ cmd.Parameters.AddWithValue ("@postId", postId.Value) + [ cmd.Parameters.AddWithValue ("@postId", string postId) cmd.Parameters.Add ("@link", SqliteType.Text) ] |> ignore let runCmd (link: Permalink) = backgroundTask { - cmd.Parameters["@link"].Value <- link.Value + cmd.Parameters["@link"].Value <- string link do! write cmd } cmd.CommandText <- "DELETE FROM post_permalink WHERE post_id = @postId AND permalink = @link" @@ -168,10 +168,10 @@ type SQLitePostData(conn: SqliteConnection, ser: JsonSerializer) = use cmd = conn.CreateCommand () let runCmd withText rev = backgroundTask { cmd.Parameters.Clear () - [ cmd.Parameters.AddWithValue ("@postId", postId.Value) + [ cmd.Parameters.AddWithValue ("@postId", string postId) cmd.Parameters.AddWithValue ("@asOf", instantParam rev.AsOf) ] |> ignore - if withText then cmd.Parameters.AddWithValue ("@text", rev.Text.Value) |> ignore + if withText then cmd.Parameters.AddWithValue ("@text", string rev.Text) |> ignore do! write cmd } cmd.CommandText <- "DELETE FROM post_revision WHERE post_id = @postId AND as_of = @asOf" @@ -212,7 +212,7 @@ type SQLitePostData(conn: SqliteConnection, ser: JsonSerializer) = 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", status.Value) |> ignore + cmd.Parameters.AddWithValue ("@status", string status) |> ignore return! count cmd } @@ -230,7 +230,7 @@ type SQLitePostData(conn: SqliteConnection, ser: JsonSerializer) = use cmd = conn.CreateCommand () cmd.CommandText <- $"{selectPost} WHERE p.web_log_id = @webLogId AND p.permalink = @link" addWebLogId cmd webLogId - cmd.Parameters.AddWithValue ("@link", permalink.Value) |> ignore + cmd.Parameters.AddWithValue ("@link", string permalink) |> ignore use! rdr = cmd.ExecuteReaderAsync () if rdr.Read () then let! post = appendPostCategoryAndTag (toPost rdr) @@ -253,7 +253,7 @@ type SQLitePostData(conn: SqliteConnection, ser: JsonSerializer) = match! findFullById postId webLogId with | Some _ -> use cmd = conn.CreateCommand () - cmd.Parameters.AddWithValue ("@id", postId.Value) |> ignore + cmd.Parameters.AddWithValue ("@id", string postId) |> ignore cmd.CommandText <- "DELETE FROM post_revision WHERE post_id = @id; DELETE FROM post_permalink WHERE post_id = @id; @@ -267,9 +267,9 @@ type SQLitePostData(conn: SqliteConnection, ser: JsonSerializer) = } /// Find the current permalink from a list of potential prior permalinks for the given web log - let findCurrentPermalink permalinks webLogId = backgroundTask { + let findCurrentPermalink (permalinks: Permalink list) webLogId = backgroundTask { use cmd = conn.CreateCommand () - let linkSql, linkParams = inClause "AND pp.permalink" "link" (fun (it: Permalink) -> it.Value) permalinks + let linkSql, linkParams = inClause "AND pp.permalink" "link" string permalinks cmd.CommandText <- $" SELECT p.permalink FROM post p @@ -299,9 +299,9 @@ type SQLitePostData(conn: SqliteConnection, ser: JsonSerializer) = } /// Get a page of categorized posts for the given web log (excludes revisions and prior permalinks) - let findPageOfCategorizedPosts webLogId categoryIds pageNbr postsPerPage = backgroundTask { + let findPageOfCategorizedPosts webLogId (categoryIds: CategoryId list) pageNbr postsPerPage = backgroundTask { use cmd = conn.CreateCommand () - let catSql, catParams = inClause "AND pc.category_id" "catId" (fun (it: CategoryId) -> it.Value) categoryIds + let catSql, catParams = inClause "AND pc.category_id" "catId" string categoryIds cmd.CommandText <- $" {selectPost} INNER JOIN post_category pc ON pc.post_id = p.id @@ -311,7 +311,7 @@ type SQLitePostData(conn: SqliteConnection, ser: JsonSerializer) = ORDER BY published_on DESC LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}" addWebLogId cmd webLogId - cmd.Parameters.AddWithValue ("@status", Published.Value) |> ignore + cmd.Parameters.AddWithValue ("@status", string Published) |> ignore cmd.Parameters.AddRange catParams use! rdr = cmd.ExecuteReaderAsync () let! posts = @@ -348,7 +348,7 @@ type SQLitePostData(conn: SqliteConnection, ser: JsonSerializer) = ORDER BY p.published_on DESC LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}" addWebLogId cmd webLogId - cmd.Parameters.AddWithValue ("@status", Published.Value) |> ignore + cmd.Parameters.AddWithValue ("@status", string Published) |> ignore use! rdr = cmd.ExecuteReaderAsync () let! posts = toList toPost rdr @@ -369,7 +369,7 @@ type SQLitePostData(conn: SqliteConnection, ser: JsonSerializer) = ORDER BY p.published_on DESC LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}" addWebLogId cmd webLogId - [ cmd.Parameters.AddWithValue ("@status", Published.Value) + [ cmd.Parameters.AddWithValue ("@status", string Published) cmd.Parameters.AddWithValue ("@tag", tag) ] |> ignore use! rdr = cmd.ExecuteReaderAsync () @@ -391,7 +391,7 @@ type SQLitePostData(conn: SqliteConnection, ser: JsonSerializer) = ORDER BY p.published_on DESC LIMIT 1" addWebLogId cmd webLogId - [ cmd.Parameters.AddWithValue ("@status", Published.Value) + [ cmd.Parameters.AddWithValue ("@status", string Published) cmd.Parameters.AddWithValue ("@publishedOn", instantParam publishedOn) ] |> ignore use! rdr = cmd.ExecuteReaderAsync () diff --git a/src/MyWebLog.Data/SQLite/SQLiteTagMapData.fs b/src/MyWebLog.Data/SQLite/SQLiteTagMapData.fs index 00de07b..d17d203 100644 --- a/src/MyWebLog.Data/SQLite/SQLiteTagMapData.fs +++ b/src/MyWebLog.Data/SQLite/SQLiteTagMapData.fs @@ -8,12 +8,12 @@ open MyWebLog.Data type SQLiteTagMapData (conn : SqliteConnection) = /// Find a tag mapping by its ID for the given web log - let findById tagMapId webLogId = backgroundTask { - use cmd = conn.CreateCommand () + let findById (tagMapId: 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 Helpers.verifyWebLog webLogId (fun tm -> tm.WebLogId) Map.toTagMap rdr + cmd.Parameters.AddWithValue ("@id", string tagMapId) |> ignore + use! rdr = cmd.ExecuteReaderAsync() + return verifyWebLog webLogId (_.WebLogId) Map.toTagMap rdr } /// Delete a tag mapping for the given web log @@ -22,7 +22,7 @@ type SQLiteTagMapData (conn : SqliteConnection) = | Some _ -> use cmd = conn.CreateCommand () cmd.CommandText <- "DELETE FROM tag_map WHERE id = @id" - cmd.Parameters.AddWithValue ("@id", TagMapId.toString tagMapId) |> ignore + cmd.Parameters.AddWithValue ("@id", string tagMapId) |> ignore do! write cmd return true | None -> return false @@ -81,7 +81,7 @@ type SQLiteTagMapData (conn : SqliteConnection) = @id, @webLogId, @tag, @urlValue )" addWebLogId cmd tagMap.WebLogId - [ cmd.Parameters.AddWithValue ("@id", TagMapId.toString tagMap.Id) + [ cmd.Parameters.AddWithValue ("@id", string tagMap.Id) cmd.Parameters.AddWithValue ("@tag", tagMap.Tag) cmd.Parameters.AddWithValue ("@urlValue", tagMap.UrlValue) ] |> ignore diff --git a/src/MyWebLog.Data/SQLite/SQLiteThemeData.fs b/src/MyWebLog.Data/SQLite/SQLiteThemeData.fs index dd3d81b..ff5403b 100644 --- a/src/MyWebLog.Data/SQLite/SQLiteThemeData.fs +++ b/src/MyWebLog.Data/SQLite/SQLiteThemeData.fs @@ -27,19 +27,19 @@ type SQLiteThemeData (conn : SqliteConnection) = } /// Does a given theme exist? - let exists themeId = backgroundTask { + let exists (themeId: ThemeId) = backgroundTask { use cmd = conn.CreateCommand () cmd.CommandText <- "SELECT COUNT(id) FROM theme WHERE id = @id" - cmd.Parameters.AddWithValue ("@id", ThemeId.toString themeId) |> ignore + cmd.Parameters.AddWithValue ("@id", string themeId) |> ignore let! count = count cmd return count > 0 } /// Find a theme by its ID - let findById themeId = backgroundTask { + let findById (themeId: ThemeId) = backgroundTask { use cmd = conn.CreateCommand () cmd.CommandText <- "SELECT * FROM theme WHERE id = @id" - cmd.Parameters.AddWithValue ("@id", ThemeId.toString themeId) |> ignore + cmd.Parameters.AddWithValue ("@id", string themeId) |> ignore use! rdr = cmd.ExecuteReaderAsync () if rdr.Read () then let theme = Map.toTheme rdr @@ -71,29 +71,28 @@ type SQLiteThemeData (conn : SqliteConnection) = "DELETE FROM theme_asset WHERE theme_id = @id; DELETE FROM theme_template WHERE theme_id = @id; DELETE FROM theme WHERE id = @id" - cmd.Parameters.AddWithValue ("@id", ThemeId.toString themeId) |> ignore + cmd.Parameters.AddWithValue ("@id", string themeId) |> ignore do! write cmd return true | None -> return false } /// Save a theme - let save (theme : Theme) = backgroundTask { - use cmd = conn.CreateCommand () + let save (theme: Theme) = backgroundTask { + use cmd = conn.CreateCommand() let! oldTheme = 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 ("@id", string theme.Id) cmd.Parameters.AddWithValue ("@name", theme.Name) cmd.Parameters.AddWithValue ("@version", theme.Version) ] |> ignore do! write cmd let toDelete, toAdd = - Utils.diffLists (oldTheme |> Option.map (fun t -> t.Templates) |> Option.defaultValue []) - theme.Templates (fun t -> t.Name) + Utils.diffLists (oldTheme |> Option.map _.Templates |> Option.defaultValue []) theme.Templates _.Name let toUpdate = theme.Templates |> List.filter (fun t -> @@ -102,7 +101,7 @@ type SQLiteThemeData (conn : SqliteConnection) = 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.AddWithValue ("@themeId", string theme.Id) cmd.Parameters.Add ("@name", SqliteType.Text) cmd.Parameters.Add ("@template", SqliteType.Text) ] |> ignore @@ -157,10 +156,10 @@ type SQLiteThemeAssetData (conn : SqliteConnection) = } /// Delete all assets for the given theme - let deleteByTheme themeId = backgroundTask { + let deleteByTheme (themeId: ThemeId) = backgroundTask { use cmd = conn.CreateCommand () cmd.CommandText <- "DELETE FROM theme_asset WHERE theme_id = @themeId" - cmd.Parameters.AddWithValue ("@themeId", ThemeId.toString themeId) |> ignore + cmd.Parameters.AddWithValue ("@themeId", string themeId) |> ignore do! write cmd } @@ -177,19 +176,19 @@ type SQLiteThemeAssetData (conn : SqliteConnection) = } /// Get theme assets for the given theme (excludes data) - let findByTheme themeId = backgroundTask { + let findByTheme (themeId: 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 + cmd.Parameters.AddWithValue ("@themeId", string themeId) |> ignore use! rdr = cmd.ExecuteReaderAsync () return toList (Map.toThemeAsset false) rdr } /// Get theme assets for the given theme - let findByThemeWithData themeId = backgroundTask { + let findByThemeWithData (themeId: 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 + cmd.Parameters.AddWithValue ("@themeId", string themeId) |> ignore use! rdr = cmd.ExecuteReaderAsync () return toList (Map.toThemeAsset true) rdr } diff --git a/src/MyWebLog.Data/SQLite/SQLiteUploadData.fs b/src/MyWebLog.Data/SQLite/SQLiteUploadData.fs index 3614b79..cf915ae 100644 --- a/src/MyWebLog.Data/SQLite/SQLiteUploadData.fs +++ b/src/MyWebLog.Data/SQLite/SQLiteUploadData.fs @@ -5,14 +5,14 @@ open Microsoft.Data.Sqlite open MyWebLog open MyWebLog.Data -/// SQLite myWebLog web log data implementation -type SQLiteUploadData (conn : SqliteConnection) = +/// SQLite myWebLog web log data implementation +type SQLiteUploadData(conn: SqliteConnection) = /// Add parameters for uploaded file INSERT and UPDATE statements - let addUploadParameters (cmd : SqliteCommand) (upload : Upload) = - [ cmd.Parameters.AddWithValue ("@id", UploadId.toString upload.Id) - cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString upload.WebLogId) - cmd.Parameters.AddWithValue ("@path", upload.Path.Value) + let addUploadParameters (cmd: SqliteCommand) (upload: Upload) = + [ cmd.Parameters.AddWithValue ("@id", string upload.Id) + cmd.Parameters.AddWithValue ("@webLogId", string upload.WebLogId) + cmd.Parameters.AddWithValue ("@path", string upload.Path) cmd.Parameters.AddWithValue ("@updatedOn", instantParam upload.UpdatedOn) cmd.Parameters.AddWithValue ("@dataLength", upload.Data.Length) ] |> ignore @@ -46,14 +46,14 @@ type SQLiteUploadData (conn : SqliteConnection) = WHERE id = @id AND web_log_id = @webLogId" addWebLogId cmd webLogId - cmd.Parameters.AddWithValue ("@id", UploadId.toString uploadId) |> ignore + cmd.Parameters.AddWithValue ("@id", string uploadId) |> ignore let! rdr = cmd.ExecuteReaderAsync () if (rdr.Read ()) then let upload = Map.toUpload false rdr do! rdr.CloseAsync () cmd.CommandText <- "DELETE FROM upload WHERE id = @id AND web_log_id = @webLogId" do! write cmd - return Ok upload.Path.Value + return Ok (string upload.Path) else return Error $"""Upload ID {cmd.Parameters["@id"]} not found""" } diff --git a/src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs b/src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs index 11a347c..ea2b4ea 100644 --- a/src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs +++ b/src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs @@ -9,8 +9,8 @@ open Newtonsoft.Json // The web log podcast insert loop is not statically compilable; this is OK #nowarn "3511" -/// SQLite myWebLog web log data implementation -type SQLiteWebLogData (conn : SqliteConnection, ser : JsonSerializer) = +/// SQLite myWebLog web log data implementation +type SQLiteWebLogData(conn: SqliteConnection, ser: JsonSerializer) = // SUPPORT FUNCTIONS @@ -25,28 +25,28 @@ type SQLiteWebLogData (conn : SqliteConnection, ser : JsonSerializer) = ] |> ignore /// Add parameters for web log INSERT or UPDATE statements - let addWebLogParameters (cmd : SqliteCommand) (webLog : WebLog) = - [ cmd.Parameters.AddWithValue ("@id", WebLogId.toString webLog.Id) + let addWebLogParameters (cmd: SqliteCommand) (webLog: WebLog) = + [ cmd.Parameters.AddWithValue ("@id", string webLog.Id) cmd.Parameters.AddWithValue ("@name", webLog.Name) cmd.Parameters.AddWithValue ("@slug", webLog.Slug) cmd.Parameters.AddWithValue ("@subtitle", maybe webLog.Subtitle) cmd.Parameters.AddWithValue ("@defaultPage", webLog.DefaultPage) cmd.Parameters.AddWithValue ("@postsPerPage", webLog.PostsPerPage) - cmd.Parameters.AddWithValue ("@themeId", ThemeId.toString webLog.ThemeId) + cmd.Parameters.AddWithValue ("@themeId", string webLog.ThemeId) cmd.Parameters.AddWithValue ("@urlBase", webLog.UrlBase) cmd.Parameters.AddWithValue ("@timeZone", webLog.TimeZone) cmd.Parameters.AddWithValue ("@autoHtmx", webLog.AutoHtmx) - cmd.Parameters.AddWithValue ("@uploads", UploadDestination.toString webLog.Uploads) + cmd.Parameters.AddWithValue ("@uploads", string webLog.Uploads) cmd.Parameters.AddWithValue ("@redirectRules", Utils.serialize ser webLog.RedirectRules) ] |> ignore addWebLogRssParameters cmd webLog /// Add parameters for custom feed INSERT or UPDATE statements - let addCustomFeedParameters (cmd : SqliteCommand) webLogId (feed : CustomFeed) = - [ cmd.Parameters.AddWithValue ("@id", CustomFeedId.toString feed.Id) - cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString webLogId) - cmd.Parameters.AddWithValue ("@source", CustomFeedSource.toString feed.Source) - cmd.Parameters.AddWithValue ("@path", feed.Path.Value) + let addCustomFeedParameters (cmd: SqliteCommand) (webLogId: WebLogId) (feed: CustomFeed) = + [ cmd.Parameters.AddWithValue ("@id", string feed.Id) + cmd.Parameters.AddWithValue ("@webLogId", string webLogId) + cmd.Parameters.AddWithValue ("@source", string feed.Source) + cmd.Parameters.AddWithValue ("@path", string feed.Path) cmd.Parameters.AddWithValue ("@podcast", maybe (if Option.isSome feed.Podcast then Some (Utils.serialize ser feed.Podcast) else None)) @@ -74,7 +74,7 @@ type SQLiteWebLogData (conn : SqliteConnection, ser : JsonSerializer) = /// Update the custom feeds for a web log let updateCustomFeeds (webLog : WebLog) = backgroundTask { let! feeds = getCustomFeeds webLog - let toDelete, toAdd = Utils.diffLists feeds webLog.Rss.CustomFeeds (fun it -> $"{CustomFeedId.toString it.Id}") + let toDelete, toAdd = Utils.diffLists feeds webLog.Rss.CustomFeeds string let toId (feed : CustomFeed) = feed.Id let toUpdate = webLog.Rss.CustomFeeds @@ -85,7 +85,7 @@ type SQLiteWebLogData (conn : SqliteConnection, ser : JsonSerializer) = toDelete |> List.map (fun it -> backgroundTask { cmd.CommandText <- "DELETE FROM web_log_feed WHERE id = @id" - cmd.Parameters["@id"].Value <- CustomFeedId.toString it.Id + cmd.Parameters["@id"].Value <- string it.Id do! write cmd }) |> Task.WhenAll @@ -211,7 +211,7 @@ type SQLiteWebLogData (conn : SqliteConnection, ser : JsonSerializer) = use cmd = conn.CreateCommand () cmd.CommandText <- "UPDATE web_log SET redirect_rules = @redirectRules WHERE id = @id" cmd.Parameters.AddWithValue ("@redirectRules", Utils.serialize ser webLog.RedirectRules) |> ignore - cmd.Parameters.AddWithValue ("@id", WebLogId.toString webLog.Id) |> ignore + cmd.Parameters.AddWithValue ("@id", string webLog.Id) |> ignore do! write cmd } @@ -228,7 +228,7 @@ type SQLiteWebLogData (conn : SqliteConnection, ser : JsonSerializer) = copyright = @copyright WHERE id = @id" addWebLogRssParameters cmd webLog - cmd.Parameters.AddWithValue ("@id", WebLogId.toString webLog.Id) |> ignore + cmd.Parameters.AddWithValue ("@id", string webLog.Id) |> ignore do! write cmd do! updateCustomFeeds webLog } diff --git a/src/MyWebLog.Data/SQLite/SQLiteWebLogUserData.fs b/src/MyWebLog.Data/SQLite/SQLiteWebLogUserData.fs index f99bf05..20a6056 100644 --- a/src/MyWebLog.Data/SQLite/SQLiteWebLogUserData.fs +++ b/src/MyWebLog.Data/SQLite/SQLiteWebLogUserData.fs @@ -4,22 +4,22 @@ open Microsoft.Data.Sqlite open MyWebLog open MyWebLog.Data -/// SQLite myWebLog user data implementation -type SQLiteWebLogUserData (conn : SqliteConnection) = +/// SQLite myWebLog user data implementation +type SQLiteWebLogUserData(conn: SqliteConnection) = // SUPPORT FUNCTIONS /// 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) + let addWebLogUserParameters (cmd: SqliteCommand) (user: WebLogUser) = + [ cmd.Parameters.AddWithValue ("@id", string user.Id) + cmd.Parameters.AddWithValue ("@webLogId", string user.WebLogId) cmd.Parameters.AddWithValue ("@email", user.Email) 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 ("@url", maybe user.Url) - cmd.Parameters.AddWithValue ("@accessLevel", user.AccessLevel.Value) + cmd.Parameters.AddWithValue ("@accessLevel", string user.AccessLevel) cmd.Parameters.AddWithValue ("@createdOn", instantParam user.CreatedOn) cmd.Parameters.AddWithValue ("@lastSeenOn", maybeInstant user.LastSeenOn) ] |> ignore @@ -42,12 +42,12 @@ type SQLiteWebLogUserData (conn : SqliteConnection) = } /// Find a user by their ID for the given web log - let findById userId webLogId = backgroundTask { + let findById (userId: WebLogUserId) webLogId = backgroundTask { use cmd = conn.CreateCommand () cmd.CommandText <- "SELECT * FROM web_log_user WHERE id = @id" - cmd.Parameters.AddWithValue ("@id", WebLogUserId.toString userId) |> ignore + cmd.Parameters.AddWithValue ("@id", string userId) |> ignore use! rdr = cmd.ExecuteReaderAsync () - return Helpers.verifyWebLog webLogId (fun u -> u.WebLogId) Map.toWebLogUser rdr + return verifyWebLog webLogId (_.WebLogId) Map.toWebLogUser rdr } /// Delete a user if they have no posts or pages @@ -56,7 +56,7 @@ type SQLiteWebLogUserData (conn : SqliteConnection) = | Some _ -> use cmd = conn.CreateCommand () cmd.CommandText <- "SELECT COUNT(id) FROM page WHERE author_id = @userId" - cmd.Parameters.AddWithValue ("@userId", WebLogUserId.toString userId) |> ignore + cmd.Parameters.AddWithValue ("@userId", string userId) |> ignore let! pageCount = count cmd cmd.CommandText <- "SELECT COUNT(id) FROM post WHERE author_id = @userId" let! postCount = count cmd @@ -89,16 +89,15 @@ type SQLiteWebLogUserData (conn : SqliteConnection) = } /// Find the names of users by their IDs for the given web log - let findNames webLogId userIds = backgroundTask { + let findNames webLogId (userIds: WebLogUserId list) = backgroundTask { use cmd = conn.CreateCommand () - let nameSql, nameParams = inClause "AND id" "id" WebLogUserId.toString userIds + let nameSql, nameParams = inClause "AND id" "id" string userIds cmd.CommandText <- $"SELECT * FROM web_log_user WHERE web_log_id = @webLogId {nameSql}" addWebLogId cmd webLogId cmd.Parameters.AddRange nameParams use! rdr = cmd.ExecuteReaderAsync () return - toList Map.toWebLogUser rdr - |> List.map (fun u -> { Name = WebLogUserId.toString u.Id; Value = WebLogUser.displayName u }) + toList Map.toWebLogUser rdr |> List.map (fun u -> { Name = string u.Id; Value = WebLogUser.displayName u }) } /// Restore users from a backup @@ -108,7 +107,7 @@ type SQLiteWebLogUserData (conn : SqliteConnection) = } /// Set a user's last seen date/time to now - let setLastSeen userId webLogId = backgroundTask { + let setLastSeen (userId: WebLogUserId) webLogId = backgroundTask { use cmd = conn.CreateCommand () cmd.CommandText <- "UPDATE web_log_user @@ -116,7 +115,7 @@ type SQLiteWebLogUserData (conn : SqliteConnection) = WHERE id = @id AND web_log_id = @webLogId" addWebLogId cmd webLogId - [ cmd.Parameters.AddWithValue ("@id", WebLogUserId.toString userId) + [ cmd.Parameters.AddWithValue ("@id", string userId) cmd.Parameters.AddWithValue ("@lastSeenOn", instantParam (Noda.now ())) ] |> ignore let! _ = cmd.ExecuteNonQueryAsync () diff --git a/src/MyWebLog.Data/SQLiteData.fs b/src/MyWebLog.Data/SQLiteData.fs index c25d9c2..e30aafd 100644 --- a/src/MyWebLog.Data/SQLiteData.fs +++ b/src/MyWebLog.Data/SQLiteData.fs @@ -203,7 +203,7 @@ type SQLiteData (conn : SqliteConnection, log : ILogger, ser : JsonS |> List.iter (fun (feedId, podcast) -> cmd.CommandText <- "UPDATE web_log_feed SET podcast = @podcast WHERE id = @id" [ cmd.Parameters.AddWithValue ("@podcast", Utils.serialize ser podcast) - cmd.Parameters.AddWithValue ("@id", CustomFeedId.toString feedId) ] |> ignore + cmd.Parameters.AddWithValue ("@id", string feedId) ] |> ignore let _ = cmd.ExecuteNonQuery () cmd.Parameters.Clear ()) cmd.CommandText <- "SELECT * FROM post_episode" @@ -241,7 +241,7 @@ type SQLiteData (conn : SqliteConnection, log : ILogger, ser : JsonS |> List.iter (fun (postId, episode) -> cmd.CommandText <- "UPDATE post SET episode = @episode WHERE id = @id" [ cmd.Parameters.AddWithValue ("@episode", Utils.serialize ser episode) - cmd.Parameters.AddWithValue ("@id", postId.Value) ] |> ignore + cmd.Parameters.AddWithValue ("@id", string postId) ] |> ignore let _ = cmd.ExecuteNonQuery () cmd.Parameters.Clear ()) diff --git a/src/MyWebLog.Data/Utils.fs b/src/MyWebLog.Data/Utils.fs index 285a5f2..2af59a7 100644 --- a/src/MyWebLog.Data/Utils.fs +++ b/src/MyWebLog.Data/Utils.fs @@ -12,7 +12,7 @@ let currentDbVersion = "v2.1" 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 = cat.Id.Value + { Id = string cat.Id Slug = fullSlug Name = cat.Name Description = cat.Description @@ -29,16 +29,16 @@ let diffLists<'T, 'U when 'U: equality> oldItems newItems (f: 'T -> 'U) = List.filter (diff newItems) oldItems, List.filter (diff oldItems) newItems /// Find meta items added and removed -let diffMetaItems (oldItems : MetaItem list) newItems = +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 (fun (it: Permalink) -> it.Value) +let diffPermalinks (oldLinks: Permalink list) newLinks = + diffLists oldLinks newLinks string /// Find the revisions added and removed -let diffRevisions oldRevs newRevs = - diffLists oldRevs newRevs (fun (rev: Revision) -> $"{rev.AsOf.ToUnixTimeTicks()}|{rev.Text.Value}") +let diffRevisions (oldRevs: Revision list) newRevs = + diffLists oldRevs newRevs (fun rev -> $"{rev.AsOf.ToUnixTimeTicks()}|{rev.Text}") open MyWebLog.Converters open Newtonsoft.Json diff --git a/src/MyWebLog.Domain/DataTypes.fs b/src/MyWebLog.Domain/DataTypes.fs index ad0c0d1..01c6a39 100644 --- a/src/MyWebLog.Domain/DataTypes.fs +++ b/src/MyWebLog.Domain/DataTypes.fs @@ -32,7 +32,7 @@ module Category = /// An empty category let empty = { Id = CategoryId.Empty - WebLogId = WebLogId.empty + WebLogId = WebLogId.Empty Name = "" Slug = "" Description = None @@ -137,8 +137,8 @@ module Page = /// An empty page let empty = { Id = PageId.Empty - WebLogId = WebLogId.empty - AuthorId = WebLogUserId.empty + WebLogId = WebLogId.Empty + AuthorId = WebLogUserId.Empty Title = "" Permalink = Permalink.Empty PublishedOn = Noda.epoch @@ -210,8 +210,8 @@ module Post = /// An empty post let empty = { Id = PostId.Empty - WebLogId = WebLogId.empty - AuthorId = WebLogUserId.empty + WebLogId = WebLogId.Empty + AuthorId = WebLogUserId.Empty Status = Draft Title = "" Permalink = Permalink.Empty @@ -248,8 +248,8 @@ module TagMap = /// An empty tag mapping let empty = { - Id = TagMapId.empty - WebLogId = WebLogId.empty + Id = TagMapId.Empty + WebLogId = WebLogId.Empty Tag = "" UrlValue = "" } @@ -328,8 +328,8 @@ module Upload = /// An empty upload let empty = { - Id = UploadId.empty - WebLogId = WebLogId.empty + Id = UploadId.Empty + WebLogId = WebLogId.Empty Path = Permalink.Empty UpdatedOn = Noda.epoch Data = [||] @@ -384,7 +384,7 @@ module WebLog = /// An empty web log let empty = { - Id = WebLogId.empty + Id = WebLogId.Empty Name = "" Slug = "" Subtitle = None @@ -393,7 +393,7 @@ module WebLog = ThemeId = ThemeId "default" UrlBase = "" TimeZone = "" - Rss = RssOptions.empty + Rss = RssOptions.Empty AutoHtmx = false Uploads = Database RedirectRules = [] @@ -407,12 +407,12 @@ module WebLog = /// Generate an absolute URL for the given link let absoluteUrl webLog (permalink: Permalink) = - $"{webLog.UrlBase}/{permalink.Value}" + $"{webLog.UrlBase}/{permalink}" /// Generate a relative URL for the given link let relativeUrl webLog (permalink: Permalink) = let _, leadPath = hostAndPath webLog - $"{leadPath}/{permalink.Value}" + $"{leadPath}/{permalink}" /// Convert an Instant (UTC reference) to the web log's local date/time let localTime webLog (date: Instant) = @@ -463,8 +463,8 @@ module WebLogUser = /// An empty web log user let empty = { - Id = WebLogUserId.empty - WebLogId = WebLogId.empty + Id = WebLogUserId.Empty + WebLogId = WebLogId.Empty Email = "" FirstName = "" LastName = "" diff --git a/src/MyWebLog.Domain/SupportTypes.fs b/src/MyWebLog.Domain/SupportTypes.fs index ed25389..ee3c01c 100644 --- a/src/MyWebLog.Domain/SupportTypes.fs +++ b/src/MyWebLog.Domain/SupportTypes.fs @@ -54,16 +54,16 @@ type AccessLevel = | Administrator /// Parse an access level from its string representation - static member Parse = - function + static member Parse level = + match level with | "Author" -> Author | "Editor" -> Editor | "WebLogAdmin" -> WebLogAdmin | "Administrator" -> Administrator - | it -> invalidArg "level" $"{it} is not a valid access level" + | _ -> invalidArg (nameof level) $"{level} is not a valid access level" /// The string representation of this access level - member this.Value = + override this.ToString() = match this with | Author -> "Author" | Editor -> "Editor" @@ -96,7 +96,7 @@ type CategoryId = newId >> CategoryId /// The string representation of this category ID - member this.Value = + override this.ToString() = match this with CategoryId it -> it @@ -113,7 +113,7 @@ type CommentId = newId >> CommentId /// The string representation of this comment ID - member this.Value = + override this.ToString() = match this with CommentId it -> it @@ -128,15 +128,15 @@ type CommentStatus = | Spam /// Parse a string into a comment status - static member Parse = - function + static member Parse status = + match status with | "Approved" -> Approved | "Pending" -> Pending | "Spam" -> Spam - | it -> invalidArg "status" $"{it} is not a valid comment status" + | _ -> invalidArg (nameof status) $"{status} is not a valid comment status" /// Convert a comment status to a string - member this.Value = + override this.ToString() = match this with Approved -> "Approved" | Pending -> "Pending" | Spam -> "Spam" @@ -148,15 +148,15 @@ type ExplicitRating = | Clean /// Parse a string into an explicit rating - static member Parse = - function + static member Parse rating = + match rating with | "yes" -> Yes | "no" -> No | "clean" -> Clean - | it -> invalidArg "rating" $"{it} is not a valid explicit rating" + | _ -> invalidArg (nameof rating) $"{rating} is not a valid explicit rating" /// The string value of this rating - member this.Value = + override this.ToString() = match this with Yes -> "yes" | No -> "no" | Clean -> "clean" @@ -289,11 +289,11 @@ type MarkupText = | Html of string /// Parse a string into a MarkupText instance - static member Parse(it: string) = - match it with - | text when text.StartsWith "Markdown: " -> Markdown text[10..] - | text when text.StartsWith "HTML: " -> Html text[6..] - | text -> invalidOp $"Cannot derive type of text ({text})" + static member Parse(text: string) = + match text with + | _ when text.StartsWith "Markdown: " -> Markdown text[10..] + | _ when text.StartsWith "HTML: " -> Html text[6..] + | _ -> invalidArg (nameof text) $"Cannot derive type of text ({text})" /// The source type for the markup text member this.SourceType = @@ -304,7 +304,8 @@ type MarkupText = match this with Markdown text -> text | Html text -> text /// The string representation of the markup text - member this.Value = $"{this.SourceType}: {this.Text}" + override this.ToString() = + $"{this.SourceType}: {this.Text}" /// The HTML representation of the markup text member this.AsHtml() = @@ -315,10 +316,10 @@ type MarkupText = [] type MetaItem = { /// The name of the metadata value - Name : string + Name: string /// The metadata value - Value : string + Value: string } with /// An empty metadata item @@ -330,10 +331,10 @@ type MetaItem = { [] type Revision = { /// When this revision was saved - AsOf : Instant + AsOf: Instant /// The text of the revision - Text : MarkupText + Text: MarkupText } with /// An empty revision @@ -350,7 +351,7 @@ type Permalink = static member Empty = Permalink "" /// The string value of this permalink - member this.Value = + override this.ToString() = match this with Permalink it -> it @@ -367,7 +368,7 @@ type PageId = newId >> PageId /// The string value of this page ID - member this.Value = + override this.ToString() = match this with PageId it -> it @@ -383,8 +384,8 @@ type PodcastMedium = | Blog /// Parse a string into a podcast medium - static member Parse = - function + static member Parse medium = + match medium with | "podcast" -> Podcast | "music" -> Music | "video" -> Video @@ -392,10 +393,10 @@ type PodcastMedium = | "audiobook" -> Audiobook | "newsletter" -> Newsletter | "blog" -> Blog - | it -> invalidArg "medium" $"{it} is not a valid podcast medium" + | _ -> invalidArg (nameof medium) $"{medium} is not a valid podcast medium" /// The string value of this podcast medium - member this.Value = + override this.ToString() = match this with | Podcast -> "podcast" | Music -> "music" @@ -415,14 +416,14 @@ type PostStatus = | Published /// Parse a string into a post status - static member Parse = - function + static member Parse status = + match status with | "Draft" -> Draft | "Published" -> Published - | it -> invalidArg "status" $"{it} is not a valid post status" + | _ -> invalidArg (nameof status) $"{status} is not a valid post status" /// The string representation of this post status - member this.Value = + override this.ToString() = match this with Draft -> "Draft" | Published -> "Published" @@ -439,7 +440,7 @@ type PostId = newId >> PostId /// Convert a post ID to a string - member this.Value = + override this.ToString() = match this with PostId it -> it @@ -465,19 +466,20 @@ type RedirectRule = { /// An identifier for a custom feed -type CustomFeedId = CustomFeedId of string +[] +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 + static member Empty = CustomFeedId "" /// Create a new custom feed ID - let create = newId >> CustomFeedId + static member Create = + newId >> CustomFeedId + + /// Convert a custom feed ID to a string + override this.ToString() = + match this with CustomFeedId it -> it /// The source for a custom feed @@ -486,99 +488,94 @@ type CustomFeedSource = | 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 = + static member Parse(source: string) = 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" + match source with + | _ when source.StartsWith "category:" -> (value >> CategoryId >> Category) source + | _ when source.StartsWith "tag:" -> (value >> Tag) source + | _ -> invalidArg (nameof source) $"{source} is not a valid feed source" + + /// Create a string version of a feed source + override this.ToString() = + match this with | Category (CategoryId catId) -> $"category:{catId}" | Tag tag -> $"tag:{tag}" /// Options for a feed that describes a podcast +[] type PodcastOptions = { /// The title of the podcast - Title : string + Title: string /// A subtitle for the podcast - Subtitle : string option + Subtitle: string option /// The number of items in the podcast feed - ItemsInFeed : int + ItemsInFeed: int /// A summary of the podcast (iTunes field) - Summary : string + Summary: string /// The display name of the podcast author (iTunes field) - DisplayedAuthor : string + DisplayedAuthor: string /// The e-mail address of the user who registered the podcast at iTunes - Email : string + Email: string /// The link to the image for the podcast - ImageUrl : Permalink + ImageUrl: Permalink /// The category from Apple Podcasts (iTunes) under which this podcast is categorized - AppleCategory : string + AppleCategory: string /// A further refinement of the categorization of this podcast (Apple Podcasts/iTunes field / values) - AppleSubcategory : string option + AppleSubcategory: string option /// The explictness rating (iTunes field) - Explicit : ExplicitRating + Explicit: ExplicitRating /// The default media type for files in this podcast - DefaultMediaType : string option + DefaultMediaType: string option /// The base URL for relative URL media files for this podcast (optional; defaults to web log base) - MediaBaseUrl : string option + MediaBaseUrl: string option /// A GUID for this podcast - PodcastGuid : Guid option + PodcastGuid: Guid option /// A URL at which information on supporting the podcast may be found (supports permalinks) - FundingUrl : string option + FundingUrl: string option /// The text to be displayed in the funding item within the feed - FundingText : string option + FundingText: string option /// The medium (what the podcast IS, not what it is ABOUT) - Medium : PodcastMedium option + Medium: PodcastMedium option } /// A custom feed +[] type CustomFeed = { /// The ID of the custom feed - Id : CustomFeedId + Id: CustomFeedId /// The source for the custom feed - Source : CustomFeedSource + Source: CustomFeedSource /// The path for the custom feed - Path : Permalink + Path: Permalink /// Podcast options, if the feed defines a podcast - Podcast : PodcastOptions option -} - -/// Functions to support custom feeds -module CustomFeed = + Podcast: PodcastOptions option +} with /// An empty custom feed - let empty = { - Id = CustomFeedId "" - Source = Category (CategoryId "") - Path = Permalink "" + static member Empty = { + Id = CustomFeedId.Empty + Source = Category CategoryId.Empty + Path = Permalink.Empty Podcast = None } @@ -587,32 +584,29 @@ module CustomFeed = [] type RssOptions = { /// Whether the site feed of posts is enabled - IsFeedEnabled : bool + IsFeedEnabled: bool /// The name of the file generated for the site feed - FeedName : string + FeedName: string /// Override the "posts per page" setting for the site feed - ItemsInFeed : int option + ItemsInFeed: int option /// Whether feeds are enabled for all categories - IsCategoryEnabled : bool + IsCategoryEnabled: bool /// Whether feeds are enabled for all tags - IsTagEnabled : bool + IsTagEnabled: bool /// A copyright string to be placed in all feeds - Copyright : string option + Copyright: string option /// Custom feeds for this web log CustomFeeds: CustomFeed list -} - -/// Functions to support RSS options -module RssOptions = +} with /// An empty set of RSS options - let empty = { + static member Empty = { IsFeedEnabled = true FeedName = "feed.xml" ItemsInFeed = None @@ -624,126 +618,126 @@ module RssOptions = /// An identifier for a tag mapping -type TagMapId = TagMapId of string +[] +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 + static member Empty = TagMapId "" /// Create a new tag mapping ID - let create = newId >> TagMapId + static member Create = + newId >> TagMapId + + /// Convert a tag mapping ID to a string + override this.ToString() = + match this with TagMapId it -> it /// 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 +[] +type ThemeId = + | ThemeId of string + + /// The string representation of a theme ID + override this.ToString() = + match this with ThemeId it -> it /// An identifier for a theme asset -type ThemeAssetId = ThemeAssetId of ThemeId * string +[] +type ThemeAssetId = + | ThemeAssetId of ThemeId * string -/// Functions to support theme asset IDs -module ThemeAssetId = + /// Convert a string into a theme asset ID + static member Parse(it : string) = + let themeIdx = it.IndexOf "/" + ThemeAssetId(ThemeId it[..(themeIdx - 1)], it[(themeIdx + 1)..]) /// 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)..]) + override this.ToString() = + match this with ThemeAssetId (ThemeId theme, asset) -> $"{theme}/{asset}" /// A template for a theme +[] type ThemeTemplate = { /// The name of the template - Name : string + Name: string /// The text of the template - Text : string -} - -/// Functions to support theme templates -module ThemeTemplate = + Text: string +} with /// An empty theme template - let empty = + static member Empty = { Name = ""; Text = "" } /// Where uploads should be placed +[] type UploadDestination = | Database | Disk -/// Functions to support upload destinations -module UploadDestination = - - /// Convert an upload destination to its string representation - let toString = function Database -> "Database" | Disk -> "Disk" - /// Parse an upload destination from its string representation - let parse value = - match value with + static member Parse destination = + match destination with | "Database" -> Database - | "Disk" -> Disk - | it -> invalidArg "destination" $"{it} is not a valid upload destination" + | "Disk" -> Disk + | _ -> invalidArg (nameof destination) $"{destination} is not a valid upload destination" + + /// The string representation of an upload destination + override this.ToString() = + match this with Database -> "Database" | Disk -> "Disk" /// An identifier for an upload -type UploadId = UploadId of string +[] +type UploadId = + | UploadId of string -/// Functions to support upload IDs -module UploadId = - /// An empty upload ID - let empty = UploadId "" - - /// Convert an upload ID to a string - let toString = function UploadId ui -> ui + static member Empty = UploadId "" /// Create a new upload ID - let create = newId >> UploadId + static member Create = + newId >> UploadId + + /// The string representation of an upload ID + override this.ToString() = + match this with UploadId it -> it /// An identifier for a web log -type WebLogId = WebLogId of string +[] +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 + static member Empty = WebLogId "" /// Create a new web log ID - let create = newId >> WebLogId - + static member Create = + newId >> WebLogId + + /// Convert a web log ID to a string + override this.ToString() = + match this with WebLogId it -> it /// An identifier for a web log user -type WebLogUserId = WebLogUserId of string - -/// Functions to support web log user IDs -module WebLogUserId = +[] +type WebLogUserId = + | WebLogUserId of string /// An empty web log user ID - let empty = WebLogUserId "" - - /// Convert a web log user ID to a string - let toString = function WebLogUserId wli -> wli + static member Empty = WebLogUserId "" /// Create a new web log user ID - let create = newId >> WebLogUserId - - + static member Create = + newId >> WebLogUserId + + /// The string representation of a web log user ID + override this.ToString() = + match this with WebLogUserId it -> it diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index 2f9a176..2005de8 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -73,30 +73,30 @@ type DisplayCategory = { /// A display version of a custom feed definition type DisplayCustomFeed = { /// The ID of the custom feed - Id : string + Id: string /// The source of the custom feed - Source : string + Source: string /// The relative path at which the custom feed is served - Path : string + Path: string /// Whether this custom feed is for a podcast - IsPodcast : bool + IsPodcast: bool } /// Support functions for custom feed displays module DisplayCustomFeed = /// Create a display version from a custom feed - let fromFeed (cats: DisplayCategory[]) (feed: CustomFeed) : DisplayCustomFeed = + let fromFeed (cats: DisplayCategory array) (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 + { Id = string feed.Id Source = source - Path = feed.Path.Value + Path = string feed.Path IsPodcast = Option.isSome feed.Podcast } @@ -137,14 +137,14 @@ type DisplayPage = /// Create a minimal display page (no text or metadata) from a database page static member FromPageMinimal webLog (page: Page) = { - Id = page.Id.Value - AuthorId = WebLogUserId.toString page.AuthorId + Id = string page.Id + AuthorId = string page.AuthorId Title = page.Title - Permalink = page.Permalink.Value + Permalink = string page.Permalink PublishedOn = WebLog.localTime webLog page.PublishedOn UpdatedOn = WebLog.localTime webLog page.UpdatedOn IsInPageList = page.IsInPageList - IsDefault = page.Id.Value = webLog.DefaultPage + IsDefault = string page.Id = webLog.DefaultPage Text = "" Metadata = [] } @@ -152,14 +152,14 @@ type DisplayPage = /// Create a display page from a database page static member FromPage webLog (page : Page) = let _, extra = WebLog.hostAndPath webLog - { Id = page.Id.Value - AuthorId = WebLogUserId.toString page.AuthorId + { Id = string page.Id + AuthorId = string page.AuthorId Title = page.Title - Permalink = page.Permalink.Value + Permalink = string page.Permalink PublishedOn = WebLog.localTime webLog page.PublishedOn UpdatedOn = WebLog.localTime webLog page.UpdatedOn IsInPageList = page.IsInPageList - IsDefault = page.Id.Value = webLog.DefaultPage + IsDefault = string page.Id = webLog.DefaultPage Text = addBaseToRelativeUrls extra page.Text Metadata = page.Metadata } @@ -195,35 +195,35 @@ open System.IO [] type DisplayTheme = { /// The ID / path slug of the theme - Id : string + Id: string /// The name of the theme - Name : string + Name: string /// The version of the theme - Version : string + Version: string /// How many templates are contained in the theme - TemplateCount : int + TemplateCount: int /// Whether the theme is in use by any web logs - IsInUse : bool + IsInUse: bool /// Whether the theme .zip file exists on the filesystem - IsOnDisk : bool + IsOnDisk: bool } /// Functions to support displaying themes module DisplayTheme = /// Create a display theme from a theme - let fromTheme inUseFunc (theme : Theme) = - { Id = ThemeId.toString theme.Id + let fromTheme inUseFunc (theme: Theme) = + { Id = string theme.Id Name = theme.Name Version = theme.Version TemplateCount = List.length theme.Templates IsInUse = inUseFunc theme.Id - IsOnDisk = File.Exists $"{ThemeId.toString theme.Id}-theme.zip" + IsOnDisk = File.Exists $"{theme.Id}-theme.zip" } @@ -231,33 +231,33 @@ module DisplayTheme = [] type DisplayUpload = { /// The ID of the uploaded file - Id : string + Id: string /// The name of the uploaded file - Name : string + Name: string /// The path at which the file is served - Path : string + Path: string /// The date/time the file was updated - UpdatedOn : DateTime option + UpdatedOn: DateTime option /// The source for this file (created from UploadDestination DU) - Source : string + Source: string } /// Functions to support displaying uploads module DisplayUpload = /// Create a display uploaded file - let fromUpload webLog source (upload : Upload) = - let path = upload.Path.Value + let fromUpload webLog (source: UploadDestination) (upload: Upload) = + let path = string upload.Path let name = Path.GetFileName path - { Id = UploadId.toString upload.Id + { Id = string upload.Id Name = name - Path = path.Replace (name, "") + Path = path.Replace(name, "") UpdatedOn = Some (WebLog.localTime webLog upload.UpdatedOn) - Source = UploadDestination.toString source + Source = string source } @@ -265,45 +265,45 @@ module DisplayUpload = [] type DisplayUser = { /// The ID of the user - Id : string + Id: string /// The user name (e-mail address) - Email : string + Email: string /// The user's first name - FirstName : string + FirstName: string /// The user's last name - LastName : string + LastName: string /// The user's preferred name - PreferredName : string + PreferredName: string /// The URL of the user's personal site - Url : string + Url: string /// The user's access level - AccessLevel : string + AccessLevel: string /// When the user was created - CreatedOn : DateTime + CreatedOn: DateTime /// When the user last logged on - LastSeenOn : Nullable + LastSeenOn: Nullable } /// Functions to support displaying a user's information module DisplayUser = /// Construct a displayed user from a web log user - let fromUser webLog (user : WebLogUser) = - { Id = WebLogUserId.toString user.Id + let fromUser webLog (user: WebLogUser) = + { Id = string user.Id Email = user.Email FirstName = user.FirstName LastName = user.LastName PreferredName = user.PreferredName Url = defaultArg user.Url "" - AccessLevel = user.AccessLevel.Value + AccessLevel = string user.AccessLevel CreatedOn = WebLog.localTime webLog user.CreatedOn LastSeenOn = user.LastSeenOn |> Option.map (WebLog.localTime webLog) |> Option.toNullable } @@ -311,30 +311,30 @@ module DisplayUser = /// 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 - } +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 +} with /// Create an edit model from an existing category - static member fromCategory (cat : Category) = - { CategoryId = cat.Id.Value + static member fromCategory (cat: Category) = + { CategoryId = string cat.Id Name = cat.Name Slug = cat.Slug Description = defaultArg cat.Description "" - ParentId = cat.ParentId |> Option.map _.Value |> Option.defaultValue "" + ParentId = cat.ParentId |> Option.map string |> Option.defaultValue "" } /// Is this a new category? @@ -437,10 +437,10 @@ type EditCustomFeedModel = static member fromFeed (feed: CustomFeed) = let rss = { EditCustomFeedModel.empty with - Id = CustomFeedId.toString feed.Id + Id = string feed.Id SourceType = match feed.Source with Category _ -> "category" | Tag _ -> "tag" SourceValue = match feed.Source with Category (CategoryId catId) -> catId | Tag tag -> tag - Path = feed.Path.Value + Path = string feed.Path } match feed.Podcast with | Some p -> @@ -452,16 +452,16 @@ type EditCustomFeedModel = Summary = p.Summary DisplayedAuthor = p.DisplayedAuthor Email = p.Email - ImageUrl = p.ImageUrl.Value + ImageUrl = string p.ImageUrl AppleCategory = p.AppleCategory AppleSubcategory = defaultArg p.AppleSubcategory "" - Explicit = p.Explicit.Value + Explicit = string p.Explicit DefaultMediaType = defaultArg p.DefaultMediaType "" MediaBaseUrl = defaultArg p.MediaBaseUrl "" FundingUrl = defaultArg p.FundingUrl "" FundingText = defaultArg p.FundingText "" PodcastGuid = p.PodcastGuid |> Option.map _.ToString().ToLowerInvariant() |> Option.defaultValue "" - Medium = p.Medium |> Option.map _.Value |> Option.defaultValue "" + Medium = p.Medium |> Option.map string |> Option.defaultValue "" } | None -> rss @@ -562,9 +562,9 @@ type EditPageModel = { | Some rev -> rev | None -> Revision.Empty let page = if page.Metadata |> List.isEmpty then { page with Metadata = [ MetaItem.Empty ] } else page - { PageId = page.Id.Value + { PageId = string page.Id Title = page.Title - Permalink = page.Permalink.Value + Permalink = string page.Permalink Template = defaultArg page.Template "" IsShownInPageList = page.IsInPageList Source = latest.Text.SourceType @@ -580,7 +580,7 @@ type EditPageModel = { member this.UpdatePage (page: Page) now = let revision = { AsOf = now; Text = MarkupText.Parse $"{this.Source}: {this.Text}" } // Detect a permalink change, and add the prior one to the prior list - match page.Permalink.Value with + match string page.Permalink with | "" -> page | link when link = this.Permalink -> page | _ -> { page with PriorPermalinks = page.Permalink :: page.PriorPermalinks } @@ -715,15 +715,15 @@ type EditPostModel = { | None -> Revision.Empty let post = if post.Metadata |> List.isEmpty then { post with Metadata = [ MetaItem.Empty ] } else post let episode = defaultArg post.Episode Episode.Empty - { PostId = post.Id.Value + { PostId = string post.Id Title = post.Title - Permalink = post.Permalink.Value + Permalink = string post.Permalink Source = latest.Text.SourceType Text = latest.Text.Text Tags = String.Join (", ", post.Tags) Template = defaultArg post.Template "" - CategoryIds = post.CategoryIds |> List.map _.Value |> Array.ofList - Status = post.Status.Value + CategoryIds = post.CategoryIds |> List.map string |> Array.ofList + Status = string post.Status DoPublish = false MetaNames = post.Metadata |> List.map _.Name |> Array.ofList MetaValues = post.Metadata |> List.map _.Value |> Array.ofList @@ -737,7 +737,7 @@ type EditPostModel = { MediaType = defaultArg episode.MediaType "" ImageUrl = defaultArg episode.ImageUrl "" Subtitle = defaultArg episode.Subtitle "" - Explicit = defaultArg (episode.Explicit |> Option.map _.Value) "" + Explicit = defaultArg (episode.Explicit |> Option.map string) "" ChapterFile = defaultArg episode.ChapterFile "" ChapterType = defaultArg episode.ChapterType "" TranscriptUrl = defaultArg episode.TranscriptUrl "" @@ -757,7 +757,7 @@ type EditPostModel = { member this.UpdatePost (post: Post) now = let revision = { AsOf = now; Text = MarkupText.Parse $"{this.Source}: {this.Text}" } // Detect a permalink change, and add the prior one to the prior list - match post.Permalink.Value with + match string post.Permalink with | "" -> post | link when link = this.Permalink -> post | _ -> { post with PriorPermalinks = post.Permalink :: post.PriorPermalinks } @@ -916,7 +916,7 @@ type EditTagMapModel = /// Create an edit model from the tag mapping static member fromMapping (tagMap : TagMap) : EditTagMapModel = - { Id = TagMapId.toString tagMap.Id + { Id = string tagMap.Id Tag = tagMap.Tag UrlValue = tagMap.UrlValue } @@ -924,39 +924,39 @@ type EditTagMapModel = /// View model to display a user's information [] -type EditUserModel = - { /// The ID of the user - Id : string +type EditUserModel = { + /// The ID of the user + Id: string - /// The user's access level - AccessLevel : string - - /// The user name (e-mail address) - Email : string + /// The user's access level + AccessLevel: string + + /// The user name (e-mail address) + Email: string - /// The URL of the user's personal site - Url : string + /// The URL of the user's personal site + Url: string - /// The user's first name - FirstName : string + /// The user's first name + FirstName: string - /// The user's last name - LastName : string + /// The user's last name + LastName: string - /// The user's preferred name - PreferredName : string - - /// The user's password - Password : string - - /// Confirmation of the user's password - PasswordConfirm : string - } + /// The user's preferred name + PreferredName: string + + /// The user's password + Password: string + + /// Confirmation of the user's password + PasswordConfirm: string +} with /// Construct a displayed user from a web log user - static member fromUser (user : WebLogUser) = - { Id = WebLogUserId.toString user.Id - AccessLevel = user.AccessLevel.Value + static member fromUser (user: WebLogUser) = + { Id = string user.Id + AccessLevel = string user.AccessLevel Url = defaultArg user.Url "" Email = user.Email FirstName = user.FirstName @@ -1020,20 +1020,20 @@ type ManagePermalinksModel = { /// Create a permalink model from a page static member fromPage (pg: Page) = - { Id = pg.Id.Value + { Id = string pg.Id Entity = "page" CurrentTitle = pg.Title - CurrentPermalink = pg.Permalink.Value - Prior = pg.PriorPermalinks |> List.map _.Value |> Array.ofList + CurrentPermalink = string pg.Permalink + Prior = pg.PriorPermalinks |> List.map string |> Array.ofList } /// Create a permalink model from a post static member fromPost (post: Post) = - { Id = post.Id.Value + { Id = string post.Id Entity = "post" CurrentTitle = post.Title - CurrentPermalink = post.Permalink.Value - Prior = post.PriorPermalinks |> List.map _.Value |> Array.ofList + CurrentPermalink = string post.Permalink + Prior = post.PriorPermalinks |> List.map string |> Array.ofList } @@ -1055,7 +1055,7 @@ type ManageRevisionsModel = /// Create a revision model from a page static member fromPage webLog (pg: Page) = - { Id = pg.Id.Value + { Id = string pg.Id Entity = "page" CurrentTitle = pg.Title Revisions = pg.Revisions |> List.map (DisplayRevision.fromRevision webLog) |> Array.ofList @@ -1063,7 +1063,7 @@ type ManageRevisionsModel = /// Create a revision model from a post static member fromPost webLog (post: Post) = - { Id = post.Id.Value + { Id = string post.Id Entity = "post" CurrentTitle = post.Title Revisions = post.Revisions |> List.map (DisplayRevision.fromRevision webLog) |> Array.ofList @@ -1114,15 +1114,15 @@ type PostListItem = { static member fromPost (webLog: WebLog) (post: Post) = let _, extra = WebLog.hostAndPath webLog let inTZ = WebLog.localTime webLog - { Id = post.Id.Value - AuthorId = WebLogUserId.toString post.AuthorId - Status = post.Status.Value + { Id = string post.Id + AuthorId = string post.AuthorId + Status = string post.Status Title = post.Title - Permalink = post.Permalink.Value + Permalink = string post.Permalink PublishedOn = post.PublishedOn |> Option.map inTZ |> Option.toNullable UpdatedOn = inTZ post.UpdatedOn Text = addBaseToRelativeUrls extra post.Text - CategoryIds = post.CategoryIds |> List.map _.Value + CategoryIds = post.CategoryIds |> List.map string Tags = post.Tags Episode = post.Episode Metadata = post.Metadata @@ -1156,46 +1156,46 @@ type PostDisplay = /// View model for editing web log settings [] -type SettingsModel = - { /// The name of the web log - Name : string +type SettingsModel = { + /// The name of the web log + Name: string - /// The slug of the web log - Slug : string - - /// The subtitle of the web log - Subtitle : string + /// The slug of the web log + Slug: string + + /// The subtitle of the web log + Subtitle: string - /// The default page - DefaultPage : string + /// The default page + DefaultPage: string - /// How many posts should appear on index pages - PostsPerPage : int + /// 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 - ThemeId : string - - /// Whether to automatically load htmx - AutoHtmx : bool - - /// The default location for uploads - Uploads : string - } + /// The time zone in which dates/times should be displayed + TimeZone: string + + /// The theme to use to display the web log + ThemeId: string + + /// Whether to automatically load htmx + AutoHtmx: bool + + /// The default location for uploads + Uploads: string +} with /// Create a settings model from a web log - static member fromWebLog (webLog : WebLog) = + static member fromWebLog (webLog: WebLog) = { Name = webLog.Name Slug = webLog.Slug Subtitle = defaultArg webLog.Subtitle "" DefaultPage = webLog.DefaultPage PostsPerPage = webLog.PostsPerPage TimeZone = webLog.TimeZone - ThemeId = ThemeId.toString webLog.ThemeId + ThemeId = string webLog.ThemeId AutoHtmx = webLog.AutoHtmx - Uploads = UploadDestination.toString webLog.Uploads + Uploads = string webLog.Uploads } /// Update a web log with settings from the form @@ -1209,7 +1209,7 @@ type SettingsModel = TimeZone = this.TimeZone ThemeId = ThemeId this.ThemeId AutoHtmx = this.AutoHtmx - Uploads = UploadDestination.parse this.Uploads + Uploads = UploadDestination.Parse this.Uploads } diff --git a/src/MyWebLog/Caches.fs b/src/MyWebLog/Caches.fs index 05bda8f..74b09d9 100644 --- a/src/MyWebLog/Caches.fs +++ b/src/MyWebLog/Caches.fs @@ -194,8 +194,8 @@ module TemplateCache = let private hasInclude = Regex ("""{% include_template \"(.*)\" %}""", RegexOptions.None, TimeSpan.FromSeconds 2) /// Get a template for the given theme and template name - let get (themeId : ThemeId) (templateName : string) (data : IData) = backgroundTask { - let templatePath = $"{ThemeId.toString themeId}/{templateName}" + let get (themeId: ThemeId) (templateName: string) (data: IData) = backgroundTask { + let templatePath = $"{themeId}/{templateName}" match _cache.ContainsKey templatePath with | true -> return Ok _cache[templatePath] | false -> @@ -215,7 +215,7 @@ module TemplateCache = if childNotFound = "" then child.Groups[1].Value else $"{childNotFound}; {child.Groups[1].Value}" "" - text <- text.Replace (child.Value, childText) + text <- text.Replace(child.Value, childText) if childNotFound <> "" then let s = if childNotFound.IndexOf ";" >= 0 then "s" else "" return Error $"Could not find the child template{s} {childNotFound} required by {templateName}" @@ -223,8 +223,8 @@ module TemplateCache = _cache[templatePath] <- Template.Parse (text, SyntaxCompatibility.DotLiquid22) return Ok _cache[templatePath] | None -> - return Error $"Theme ID {ThemeId.toString themeId} does not have a template named {templateName}" - | None -> return Result.Error $"Theme ID {ThemeId.toString themeId} does not exist" + return Error $"Theme ID {themeId} does not have a template named {templateName}" + | None -> return Error $"Theme ID {themeId} does not exist" } /// Get all theme/template names currently cached @@ -232,16 +232,16 @@ module TemplateCache = _cache.Keys |> Seq.sort |> Seq.toList /// Invalidate all template cache entries for the given theme ID - let invalidateTheme (themeId : ThemeId) = - let keyPrefix = ThemeId.toString themeId + let invalidateTheme (themeId: ThemeId) = + let keyPrefix = string themeId _cache.Keys - |> Seq.filter (fun key -> key.StartsWith keyPrefix) + |> Seq.filter _.StartsWith(keyPrefix) |> List.ofSeq |> List.iter (fun key -> match _cache.TryRemove key with _, _ -> ()) /// Remove all entries from the template cache let empty () = - _cache.Clear () + _cache.Clear() /// A cache of asset names by themes diff --git a/src/MyWebLog/DotLiquidBespoke.fs b/src/MyWebLog/DotLiquidBespoke.fs index 45006b7..2718d43 100644 --- a/src/MyWebLog/DotLiquidBespoke.fs +++ b/src/MyWebLog/DotLiquidBespoke.fs @@ -95,9 +95,9 @@ type NavLinkFilter () = /// A filter to generate a link for theme asset (image, stylesheet, script, etc.) -type ThemeAssetFilter () = - static member ThemeAsset (ctx : Context, asset : string) = - WebLog.relativeUrl ctx.WebLog (Permalink $"themes/{ThemeId.toString ctx.WebLog.ThemeId}/{asset}") +type ThemeAssetFilter() = + static member ThemeAsset(ctx: Context, asset: string) = + WebLog.relativeUrl ctx.WebLog (Permalink $"themes/{ctx.WebLog.ThemeId}/{asset}") /// Create various items in the page header based on the state of the page being generated diff --git a/src/MyWebLog/Handlers/Admin.fs b/src/MyWebLog/Handlers/Admin.fs index 0e9af33..42bed16 100644 --- a/src/MyWebLog/Handlers/Admin.fs +++ b/src/MyWebLog/Handlers/Admin.fs @@ -37,7 +37,7 @@ module Dashboard = let admin : HttpHandler = requireAccess Administrator >=> fun next ctx -> task { match! TemplateCache.get adminTheme "theme-list-body" ctx.Data with | Ok bodyTemplate -> - let! themes = ctx.Data.Theme.All () + let! themes = ctx.Data.Theme.All() let cachedTemplates = TemplateCache.allNames () let! hash = hashForPage "myWebLog Administration" @@ -50,10 +50,10 @@ module Dashboard = themes |> Seq.ofList |> Seq.map (fun it -> [| - ThemeId.toString it.Id + string it.Id it.Name cachedTemplates - |> List.filter (fun n -> n.StartsWith (ThemeId.toString it.Id)) + |> List.filter _.StartsWith(string it.Id) |> List.length |> string |]) @@ -61,8 +61,8 @@ module Dashboard = |> addToHash "web_logs" ( WebLogCache.all () |> Seq.ofList - |> Seq.sortBy (fun it -> it.Name) - |> Seq.map (fun it -> [| WebLogId.toString it.Id; it.Name; it.UrlBase |]) + |> Seq.sortBy _.Name + |> Seq.map (fun it -> [| string it.Id; it.Name; it.UrlBase |]) |> Array.ofSeq) |> addViewContext ctx return! @@ -317,7 +317,7 @@ module TagMapping = addToHash "mappings" mappings hash |> addToHash "mapping_ids" ( mappings - |> List.map (fun it -> { Name = it.Tag; Value = TagMapId.toString it.Id })) + |> List.map (fun it -> { Name = it.Tag; Value = string it.Id })) } // GET /admin/settings/tag-mappings @@ -348,13 +348,13 @@ module TagMapping = // POST /admin/settings/tag-mapping/save let save : HttpHandler = fun next ctx -> task { let data = ctx.Data - let! model = ctx.BindFormAsync () + let! model = ctx.BindFormAsync() let tagMap = - if model.IsNew then someTask { TagMap.empty with Id = TagMapId.create (); WebLogId = ctx.WebLog.Id } + if model.IsNew then someTask { 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! 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! all next ctx | None -> return! Error.notFound next ctx @@ -395,17 +395,17 @@ module Theme = |> adminBareView "theme-upload" 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 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 () + 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 displayName = if parts[0] > "" then parts[0] else string 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 () } + | None -> return { theme with Name = string theme.Id; Version = now () } } /// Update the theme with all templates from the ZIP archive @@ -476,16 +476,16 @@ module Theme = let data = ctx.Data let! exists = data.Theme.Exists themeId let isNew = not exists - let! model = ctx.BindFormAsync () + let! model = ctx.BindFormAsync() if isNew || model.DoOverwrite then // Load the theme to the database - use stream = new MemoryStream () + use stream = new MemoryStream() do! themeFile.CopyToAsync stream let! _ = loadFromZip themeId stream data do! ThemeAssetCache.refreshTheme themeId data TemplateCache.invalidateTheme themeId // Save the .zip file - use file = new FileStream ($"{ThemeId.toString themeId}-theme.zip", FileMode.Create) + use file = new FileStream($"{themeId}-theme.zip", FileMode.Create) do! themeFile.CopyToAsync file do! addMessage ctx { UserMessage.success with @@ -556,18 +556,18 @@ module WebLog = KeyValuePair.Create("posts", "- First Page of Posts -") yield! allPages |> List.sortBy _.Title.ToLower() - |> List.map (fun p -> KeyValuePair.Create(p.Id.Value, p.Title)) + |> List.map (fun p -> KeyValuePair.Create(string p.Id, p.Title)) } |> Array.ofSeq) |> addToHash "themes" ( themes |> Seq.ofList |> Seq.map (fun it -> - KeyValuePair.Create (ThemeId.toString it.Id, $"{it.Name} (v{it.Version})")) + KeyValuePair.Create(string it.Id, $"{it.Name} (v{it.Version})")) |> Array.ofSeq) |> addToHash "upload_values" [| - KeyValuePair.Create (UploadDestination.toString Database, "Database") - KeyValuePair.Create (UploadDestination.toString Disk, "Disk") + KeyValuePair.Create(string Database, "Database") + KeyValuePair.Create(string Disk, "Disk") |] |> addToHash "users" (users |> List.map (DisplayUser.fromUser ctx.WebLog) |> Array.ofList) |> addToHash "rss_model" (EditRssModel.fromRssOptions ctx.WebLog.Rss) diff --git a/src/MyWebLog/Handlers/Feed.fs b/src/MyWebLog/Handlers/Feed.fs index 1d8dcda..605b038 100644 --- a/src/MyWebLog/Handlers/Feed.fs +++ b/src/MyWebLog/Handlers/Feed.fs @@ -37,7 +37,7 @@ let deriveFeedType (ctx : HttpContext) feedPath : (FeedType * int) option = | false -> // Category and tag feeds are handled by defined routes; check for custom feed match webLog.Rss.CustomFeeds - |> List.tryFind (fun it -> feedPath.EndsWith it.Path.Value) with + |> List.tryFind (fun it -> feedPath.EndsWith(string it.Path)) with | Some feed -> debug (fun () -> "Found custom feed") Some (Custom (feed, feedPath), feed.Podcast |> Option.map _.ItemsInFeed |> Option.defaultValue postCount) @@ -48,7 +48,7 @@ let deriveFeedType (ctx : HttpContext) feedPath : (FeedType * int) option = /// Determine the function to retrieve posts for the given feed let private getFeedPosts ctx feedType = let childIds (catId: CategoryId) = - let cat = CategoryCache.get ctx |> Array.find (fun c -> c.Id = catId.Value) + let cat = CategoryCache.get ctx |> Array.find (fun c -> c.Id = string catId) getCategoryIds cat.Slug ctx let data = ctx.Data match feedType with @@ -86,51 +86,50 @@ module private Namespace = 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 private toFeedItem webLog (authors: MetaItem list) (cats: DisplayCategory array) (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 ( + let item = SyndicationItem( Id = WebLog.absoluteUrl webLog post.Permalink, Title = TextSyndicationContent.CreateHtmlContent post.Title, - PublishDate = post.PublishedOn.Value.ToDateTimeOffset (), - LastUpdatedTime = post.UpdatedOn.ToDateTimeOffset (), + PublishDate = post.PublishedOn.Value.ToDateTimeOffset(), + LastUpdatedTime = post.UpdatedOn.ToDateTimeOffset(), Content = TextSyndicationContent.CreatePlaintextContent plainText) item.AddPermalink (Uri item.Id) - let xmlDoc = XmlDocument () + 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) + .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)) + item.Authors.Add(SyndicationPerson(Name = (authors |> List.find (fun a -> a.Name = string post.AuthorId)).Value)) [ post.CategoryIds |> List.map (fun catId -> - let cat = cats |> Array.find (fun c -> c.Id = catId.Value) - SyndicationCategory (cat.Name, WebLog.absoluteUrl webLog (Permalink $"category/{cat.Slug}/"), cat.Name)) + let cat = cats |> Array.find (fun c -> c.Id = string 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)")) + SyndicationCategory(tag, WebLog.absoluteUrl webLog (Permalink $"tag/{urlTag}/"), $"{tag} (tag)")) ] |> List.concat |> List.iter item.Categories.Add item /// Convert non-absolute URLs to an absolute URL for this web log -let toAbsolute webLog (link : string) = +let toAbsolute webLog (link: string) = if link.StartsWith "http" then link else WebLog.absoluteUrl webLog (Permalink link) /// Add episode information to a podcast feed item @@ -141,8 +140,8 @@ let private addEpisode webLog (podcast : PodcastOptions) (episode : Episode) (po | link when Option.isSome podcast.MediaBaseUrl -> $"{podcast.MediaBaseUrl.Value}{link}" | link -> WebLog.absoluteUrl webLog (Permalink link) let epMediaType = [ episode.MediaType; podcast.DefaultMediaType ] |> List.tryFind Option.isSome |> Option.flatten - let epImageUrl = defaultArg episode.ImageUrl podcast.ImageUrl.Value |> toAbsolute webLog - let epExplicit = (defaultArg episode.Explicit podcast.Explicit).Value + let epImageUrl = defaultArg episode.ImageUrl (string podcast.ImageUrl) |> toAbsolute webLog + let epExplicit = string (defaultArg episode.Explicit podcast.Explicit) let xmlDoc = XmlDocument() let enclosure = @@ -298,7 +297,7 @@ let private addPodcast webLog (rssFeed : SyndicationFeed) (feed : CustomFeed) = 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, podcast.Explicit.Value) + rssFeed.ElementExtensions.Add("explicit", Namespace.iTunes, string podcast.Explicit) podcast.Subtitle |> Option.iter (fun sub -> rssFeed.ElementExtensions.Add ("subtitle", Namespace.iTunes, sub)) podcast.FundingUrl |> Option.iter (fun url -> @@ -309,7 +308,7 @@ let private addPodcast webLog (rssFeed : SyndicationFeed) (feed : CustomFeed) = podcast.PodcastGuid |> Option.iter (fun guid -> rssFeed.ElementExtensions.Add("guid", Namespace.podcast, guid.ToString().ToLowerInvariant())) - podcast.Medium |> Option.iter (fun med -> rssFeed.ElementExtensions.Add("medium", Namespace.podcast, med.Value)) + podcast.Medium |> Option.iter (fun med -> rssFeed.ElementExtensions.Add("medium", Namespace.podcast, string med)) /// Get the feed's self reference and non-feed link let private selfAndLink webLog feedType ctx = @@ -368,7 +367,7 @@ let createFeed (feedType : FeedType) posts : HttpHandler = fun next ctx -> backg match podcast, post.Episode with | Some feed, Some episode -> addEpisode webLog (Option.get feed.Podcast) episode post item | Some _, _ -> - warn "Feed" ctx $"[{webLog.Name} {self.Value}] \"{stripHtml post.Title}\" has no media" + warn "Feed" ctx $"[{webLog.Name} {self}] \"{stripHtml post.Title}\" has no media" item | _ -> item @@ -427,7 +426,7 @@ let saveSettings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> t let editCustomFeed feedId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> let customFeed = match feedId with - | "new" -> Some { CustomFeed.empty with Id = CustomFeedId "new" } + | "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 -> @@ -436,13 +435,13 @@ let editCustomFeed feedId : HttpHandler = requireAccess WebLogAdmin >=> fun next |> addToHash ViewContext.Model (EditCustomFeedModel.fromFeed f) |> addToHash "medium_values" [| KeyValuePair.Create("", "– Unspecified –") - KeyValuePair.Create(Podcast.Value, "Podcast") - KeyValuePair.Create(Music.Value, "Music") - KeyValuePair.Create(Video.Value, "Video") - KeyValuePair.Create(Film.Value, "Film") - KeyValuePair.Create(Audiobook.Value, "Audiobook") - KeyValuePair.Create(Newsletter.Value, "Newsletter") - KeyValuePair.Create(Blog.Value, "Blog") + KeyValuePair.Create(string Podcast, "Podcast") + KeyValuePair.Create(string Music, "Music") + KeyValuePair.Create(string Video, "Video") + KeyValuePair.Create(string Film, "Film") + KeyValuePair.Create(string Audiobook, "Audiobook") + KeyValuePair.Create(string Newsletter, "Newsletter") + KeyValuePair.Create(string Blog, "Blog") |] |> adminView "custom-feed-edit" next ctx | None -> Error.notFound next ctx @@ -455,8 +454,8 @@ let saveCustomFeed : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> 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) + | "new" -> Some { CustomFeed.Empty with Id = CustomFeedId.Create() } + | _ -> webLog.Rss.CustomFeeds |> List.tryFind (fun it -> string 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)) @@ -467,7 +466,7 @@ let saveCustomFeed : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> UserMessage.success with Message = $"""Successfully {if model.Id = "new" then "add" else "sav"}ed custom feed""" } - return! redirectToGet $"admin/settings/rss/{CustomFeedId.toString feed.Id}/edit" next ctx + return! redirectToGet $"admin/settings/rss/{feed.Id}/edit" next ctx | None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx } diff --git a/src/MyWebLog/Handlers/Helpers.fs b/src/MyWebLog/Handlers/Helpers.fs index b1f4bd3..a2a9ded 100644 --- a/src/MyWebLog/Handlers/Helpers.fs +++ b/src/MyWebLog/Handlers/Helpers.fs @@ -352,8 +352,8 @@ let requireAccess level : HttpHandler = fun next ctx -> task { | Some userLevel -> do! addMessage ctx { UserMessage.warning with - Message = $"The page you tried to access requires {level.Value} privileges" - Detail = Some $"Your account only has {userLevel.Value} privileges" + Message = $"The page you tried to access requires {level} privileges" + Detail = Some $"Your account only has {userLevel} privileges" } return! Error.notAuthorized next ctx | None -> diff --git a/src/MyWebLog/Handlers/Page.fs b/src/MyWebLog/Handlers/Page.fs index 1eece85..1c442f5 100644 --- a/src/MyWebLog/Handlers/Page.fs +++ b/src/MyWebLog/Handlers/Page.fs @@ -193,7 +193,7 @@ let save : HttpHandler = requireAccess Author >=> fun next ctx -> task { do! (if model.IsNew then data.Page.Add else data.Page.Update) updatedPage if updateList then do! PageListCache.update ctx do! addMessage ctx { UserMessage.success with Message = "Page saved successfully" } - return! redirectToGet $"admin/page/{page.Id.Value}/edit" next ctx + return! redirectToGet $"admin/page/{page.Id}/edit" next ctx | Some _ -> return! Error.notAuthorized next ctx | None -> return! Error.notFound next ctx } diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index d9e87cd..ee8b3db 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -58,7 +58,7 @@ let preparePostList webLog posts listType (url: string) pageNbr perPage (data: I | _ -> Task.FromResult (None, None) let newerLink = match listType, pageNbr with - | SinglePost, _ -> newerPost |> Option.map _.Permalink.Value + | SinglePost, _ -> newerPost |> Option.map (fun it -> string it.Permalink) | _, 1 -> None | PostList, 2 when webLog.DefaultPage = "posts" -> Some "" | PostList, _ -> relUrl $"page/{pageNbr - 1}" @@ -70,7 +70,7 @@ let preparePostList webLog posts listType (url: string) pageNbr perPage (data: I | AdminList, _ -> relUrl $"admin/posts/page/{pageNbr - 1}" let olderLink = match listType, List.length posts > perPage with - | SinglePost, _ -> olderPost |> Option.map _.Permalink.Value + | SinglePost, _ -> olderPost |> Option.map (fun it -> string it.Permalink) | _, false -> None | PostList, true -> relUrl $"page/{pageNbr + 1}" | CategoryList, true -> relUrl $"category/{url}/page/{pageNbr + 1}" @@ -243,9 +243,9 @@ let edit postId : HttpHandler = requireAccess Author >=> fun next ctx -> task { |> addToHash "templates" templates |> addToHash "explicit_values" [| KeyValuePair.Create("", "– Default –") - KeyValuePair.Create(Yes.Value, "Yes") - KeyValuePair.Create(No.Value, "No") - KeyValuePair.Create(Clean.Value, "Clean") + KeyValuePair.Create(string Yes, "Yes") + KeyValuePair.Create(string No, "No") + KeyValuePair.Create(string Clean, "Clean") |] |> adminView "post-edit" next ctx | Some _ -> return! Error.notAuthorized next ctx @@ -410,7 +410,7 @@ let save : HttpHandler = requireAccess Author >=> fun next ctx -> task { |> List.length = List.length priorCats) then do! CategoryCache.update ctx do! addMessage ctx { UserMessage.success with Message = "Post saved successfully" } - return! redirectToGet $"admin/post/{post.Id.Value}/edit" next ctx + return! redirectToGet $"admin/post/{post.Id}/edit" next ctx | Some _ -> return! Error.notAuthorized next ctx | None -> return! Error.notFound next ctx } diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index c128864..72582ad 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -88,13 +88,13 @@ module CatchAll = module Asset = // GET /theme/{theme}/{**path} - let serve (urlParts : string seq) : HttpHandler = fun next ctx -> task { + let serve (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 + match! ctx.Data.ThemeAsset.FindById(ThemeAssetId.Parse path) with | Some asset -> match Upload.checkModified asset.UpdatedOn ctx with | Some threeOhFour -> return! threeOhFour next ctx - | None -> return! Upload.sendFile (asset.UpdatedOn.ToDateTimeUtc ()) path asset.Data next ctx + | None -> return! Upload.sendFile (asset.UpdatedOn.ToDateTimeUtc()) path asset.Data next ctx | None -> return! Error.notFound next ctx } diff --git a/src/MyWebLog/Handlers/Upload.fs b/src/MyWebLog/Handlers/Upload.fs index c1c840d..f192f76 100644 --- a/src/MyWebLog/Handlers/Upload.fs +++ b/src/MyWebLog/Handlers/Upload.fs @@ -107,7 +107,7 @@ let list : HttpHandler = requireAccess Author >=> fun next ctx -> task { Name = name Path = file.Replace($"{path}{slash}", "").Replace(name, "").Replace (slash, '/') UpdatedOn = create - Source = UploadDestination.toString Disk + Source = string Disk }) |> List.ofSeq with @@ -131,7 +131,7 @@ let list : HttpHandler = requireAccess Author >=> fun next ctx -> task { let showNew : HttpHandler = requireAccess Author >=> fun next ctx -> hashForPage "Upload a File" |> withAntiCsrf ctx - |> addToHash "destination" (UploadDestination.toString ctx.WebLog.Uploads) + |> addToHash "destination" (string ctx.WebLog.Uploads) |> adminView "upload-new" next ctx @@ -144,29 +144,29 @@ let save : HttpHandler = requireAccess Author >=> fun next ctx -> task { if ctx.Request.HasFormContentType && ctx.Request.Form.Files.Count > 0 then let upload = Seq.head ctx.Request.Form.Files let fileName = String.Concat (makeSlug (Path.GetFileNameWithoutExtension upload.FileName), - Path.GetExtension(upload.FileName).ToLowerInvariant ()) + Path.GetExtension(upload.FileName).ToLowerInvariant()) let now = Noda.now () let localNow = WebLog.localTime ctx.WebLog now let year = localNow.ToString "yyyy" let month = localNow.ToString "MM" - let! form = ctx.BindFormAsync () + let! form = ctx.BindFormAsync() - match UploadDestination.parse form.Destination with + match UploadDestination.Parse form.Destination with | Database -> - use stream = new MemoryStream () + use stream = new MemoryStream() do! upload.CopyToAsync stream let file = - { Id = UploadId.create () + { Id = UploadId.Create() WebLogId = ctx.WebLog.Id Path = Permalink $"{year}/{month}/{fileName}" UpdatedOn = now - Data = stream.ToArray () + Data = stream.ToArray() } do! ctx.Data.Upload.Add file | Disk -> - let fullPath = Path.Combine (uploadDir, ctx.WebLog.Slug, year, month) + let fullPath = Path.Combine(uploadDir, ctx.WebLog.Slug, year, month) let _ = Directory.CreateDirectory fullPath - use stream = new FileStream (Path.Combine (fullPath, fileName), FileMode.Create) + use stream = new FileStream(Path.Combine(fullPath, fileName), FileMode.Create) do! upload.CopyToAsync stream do! addMessage ctx { UserMessage.success with Message = $"File uploaded to {form.Destination} successfully" } diff --git a/src/MyWebLog/Handlers/User.fs b/src/MyWebLog/Handlers/User.fs index 389fe56..f9a039d 100644 --- a/src/MyWebLog/Handlers/User.fs +++ b/src/MyWebLog/Handlers/User.fs @@ -48,22 +48,22 @@ open Microsoft.AspNetCore.Authentication.Cookies // POST /user/log-on let doLogOn : HttpHandler = fun next ctx -> task { - let! model = ctx.BindFormAsync () + let! model = ctx.BindFormAsync() let data = ctx.Data let! tryUser = data.WebLogUser.FindByEmail model.EmailAddress ctx.WebLog.Id match! verifyPassword tryUser model.Password ctx with | Ok _ -> let user = tryUser.Value 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.AccessLevel.Value) + Claim(ClaimTypes.NameIdentifier, string user.Id) + Claim(ClaimTypes.Name, $"{user.FirstName} {user.LastName}") + Claim(ClaimTypes.GivenName, user.PreferredName) + Claim(ClaimTypes.Role, string user.AccessLevel) } - let identity = ClaimsIdentity (claims, CookieAuthenticationDefaults.AuthenticationScheme) + let identity = ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme) - do! ctx.SignInAsync (identity.AuthenticationType, ClaimsPrincipal identity, - AuthenticationProperties (IssuedUtc = DateTimeOffset.UtcNow)) + do! ctx.SignInAsync(identity.AuthenticationType, ClaimsPrincipal identity, + AuthenticationProperties(IssuedUtc = DateTimeOffset.UtcNow)) do! data.WebLogUser.SetLastSeen user.Id user.WebLogId do! addMessage ctx { UserMessage.success with @@ -110,10 +110,10 @@ let private showEdit (model : EditUserModel) : HttpHandler = fun next ctx -> |> withAntiCsrf ctx |> addToHash ViewContext.Model model |> addToHash "access_levels" [| - KeyValuePair.Create(Author.Value, "Author") - KeyValuePair.Create(Editor.Value, "Editor") - KeyValuePair.Create(WebLogAdmin.Value, "Web Log Admin") - if ctx.HasAccessLevel Administrator then KeyValuePair.Create(Administrator.Value, "Administrator") + KeyValuePair.Create(string Author, "Author") + KeyValuePair.Create(string Editor, "Editor") + KeyValuePair.Create(string WebLogAdmin, "Web Log Admin") + if ctx.HasAccessLevel Administrator then KeyValuePair.Create(string Administrator, "Administrator") |] |> adminBareView "user-edit" next ctx @@ -159,7 +159,7 @@ let private showMyInfo (model : EditMyInfoModel) (user : WebLogUser) : HttpHandl hashForPage "Edit Your Information" |> withAntiCsrf ctx |> addToHash ViewContext.Model model - |> addToHash "access_level" (user.AccessLevel.Value) + |> addToHash "access_level" (string user.AccessLevel) |> addToHash "created_on" (WebLog.localTime ctx.WebLog user.CreatedOn) |> addToHash "last_seen_on" (WebLog.localTime ctx.WebLog (defaultArg user.LastSeenOn (Instant.FromUnixTimeSeconds 0))) @@ -208,7 +208,7 @@ let save : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let tryUser = if model.IsNew then { WebLogUser.empty with - Id = WebLogUserId.create () + Id = WebLogUserId.Create() WebLogId = ctx.WebLog.Id CreatedOn = Noda.now () } |> someTask diff --git a/src/MyWebLog/Maintenance.fs b/src/MyWebLog/Maintenance.fs index 8d0f68f..685c9dc 100644 --- a/src/MyWebLog/Maintenance.fs +++ b/src/MyWebLog/Maintenance.fs @@ -21,8 +21,8 @@ let private doCreateWebLog (args : string[]) (sp : IServiceProvider) = task { | false, _ -> raise <| TimeZoneNotFoundException $"Cannot find IANA timezone for {local}" // Create the web log - let webLogId = WebLogId.create () - let userId = WebLogUserId.create () + let webLogId = WebLogId.Create() + let userId = WebLogUserId.Create() let homePageId = PageId.Create() let slug = Handlers.Upload.makeSlug args[2] @@ -37,7 +37,7 @@ let private doCreateWebLog (args : string[]) (sp : IServiceProvider) = task { Name = args[2] Slug = slug UrlBase = args[1] - DefaultPage = homePageId.Value + DefaultPage = string homePageId TimeZone = timeZone } @@ -110,8 +110,8 @@ let private importPriorPermalinks urlBase file (sp : IServiceProvider) = task { let! withLinks = data.Post.FindFullById post.Id post.WebLogId let! _ = data.Post.UpdatePriorPermalinks post.Id post.WebLogId (old :: withLinks.Value.PriorPermalinks) - printfn $"{old.Value} -> {current.Value}" - | None -> eprintfn $"Cannot find current post for {current.Value}" + printfn $"{old} -> {current}" + | None -> eprintfn $"Cannot find current post for {current}" printfn "Done!" | None -> eprintfn $"No web log found at {urlBase}" } @@ -144,7 +144,7 @@ let loadTheme (args : string[]) (sp : IServiceProvider) = task { let! theme = Handlers.Admin.Theme.loadFromZip themeId copy data let fac = sp.GetRequiredService () let log = fac.CreateLogger "MyWebLog.Themes" - log.LogInformation $"{theme.Name} v{theme.Version} ({ThemeId.toString theme.Id}) loaded" + log.LogInformation $"{theme.Name} v{theme.Version} ({theme.Id}) loaded" | Error message -> eprintfn $"{message}" else eprintfn "Usage: myWebLog load-theme [theme-zip-file-name]" @@ -333,13 +333,13 @@ module Backup = return { archive with WebLog = { archive.WebLog with UrlBase = defaultArg newUrlBase webLog.UrlBase } } | 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 - let newUpIds = archive.Uploads |> List.map (fun up -> up.Id, UploadId.create ()) |> dict + 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 + let newUpIds = archive.Uploads |> List.map (fun up -> up.Id, UploadId.Create() ) |> dict return { archive with WebLog = { archive.WebLog with Id = newWebLogId; UrlBase = Option.get newUrlBase } @@ -481,7 +481,7 @@ let private doUserUpgrade urlBase email (data : IData) = task { | WebLogAdmin -> do! data.WebLogUser.Update { user with AccessLevel = Administrator } printfn $"{email} is now an Administrator user" - | other -> eprintfn $"ERROR: {email} is an {other.Value}, not a WebLogAdmin" + | other -> eprintfn $"ERROR: {email} is an {other}, not a WebLogAdmin" | None -> eprintfn $"ERROR: no user {email} found at {urlBase}" | None -> eprintfn $"ERROR: no web log found for {urlBase}" } diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index a48fc2e..450b383 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -15,7 +15,7 @@ type WebLogMiddleware (next : RequestDelegate, log : ILogger) 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}" + if isDebug then log.LogDebug $"Resolved web log {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 -- 2.45.1 From c3d615d10a5555cc18e35a51a943abae617aa43d Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 16 Dec 2023 14:05:45 -0500 Subject: [PATCH 017/123] WIP on module/member conversion Data types complete --- src/MyWebLog.Data/MyWebLog.Data.fsproj | 1 - .../Postgres/PostgresCategoryData.fs | 6 +- .../Postgres/PostgresPageData.fs | 10 +- .../Postgres/PostgresPostData.fs | 20 +- .../Postgres/PostgresTagMapData.fs | 2 +- .../Postgres/PostgresWebLogData.fs | 2 +- .../Postgres/PostgresWebLogUserData.fs | 4 +- src/MyWebLog.Data/PostgresData.fs | 22 +- src/MyWebLog.Data/RethinkDbData.fs | 254 ++++++++-------- src/MyWebLog.Data/SQLite/Helpers.fs | 6 +- .../SQLite/SQLiteWebLogUserData.fs | 3 +- src/MyWebLog.Domain/DataTypes.fs | 284 ++++++++---------- src/MyWebLog.Domain/MyWebLog.Domain.fsproj | 1 + src/MyWebLog.Domain/SupportTypes.fs | 10 +- src/MyWebLog.Domain/ViewModels.fs | 144 +++++---- src/MyWebLog/Caches.fs | 10 +- src/MyWebLog/DotLiquidBespoke.fs | 76 ++--- src/MyWebLog/Handlers/Admin.fs | 10 +- src/MyWebLog/Handlers/Feed.fs | 73 ++--- src/MyWebLog/Handlers/Helpers.fs | 2 +- src/MyWebLog/Handlers/Page.fs | 7 +- src/MyWebLog/Handlers/Post.fs | 13 +- src/MyWebLog/Handlers/Routes.fs | 18 +- src/MyWebLog/Handlers/Upload.fs | 2 +- src/MyWebLog/Handlers/User.fs | 16 +- src/MyWebLog/Maintenance.fs | 6 +- 26 files changed, 481 insertions(+), 521 deletions(-) diff --git a/src/MyWebLog.Data/MyWebLog.Data.fsproj b/src/MyWebLog.Data/MyWebLog.Data.fsproj index 707825c..442d62f 100644 --- a/src/MyWebLog.Data/MyWebLog.Data.fsproj +++ b/src/MyWebLog.Data/MyWebLog.Data.fsproj @@ -10,7 +10,6 @@ - diff --git a/src/MyWebLog.Data/Postgres/PostgresCategoryData.fs b/src/MyWebLog.Data/Postgres/PostgresCategoryData.fs index b78be64..a740ea8 100644 --- a/src/MyWebLog.Data/Postgres/PostgresCategoryData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresCategoryData.fs @@ -23,7 +23,7 @@ type PostgresCategoryData(log: ILogger) = let findAllForView webLogId = backgroundTask { log.LogTrace "Category.findAllForView" let! cats = - Custom.list $"{selectWithCriteria Table.Category} ORDER BY LOWER(data ->> '{nameof Category.empty.Name}')" + Custom.list $"{selectWithCriteria Table.Category} ORDER BY LOWER(data ->> '{nameof Category.Empty.Name}')" [ webLogContains webLogId ] fromData let ordered = Utils.orderByHierarchy cats None None [] let counts = @@ -36,7 +36,7 @@ type PostgresCategoryData(log: ILogger) = |> Seq.map _.Id |> Seq.append (Seq.singleton it.Id) |> List.ofSeq - |> arrayContains (nameof Post.empty.CategoryIds) id + |> arrayContains (nameof Post.Empty.CategoryIds) id let postCount = Custom.scalar $"""SELECT COUNT(DISTINCT id) AS {countName} @@ -97,7 +97,7 @@ type PostgresCategoryData(log: ILogger) = () // Delete the category off all posts where it is assigned let! posts = - Custom.list $"SELECT data FROM {Table.Post} WHERE data -> '{nameof Post.empty.CategoryIds}' @> @id" + Custom.list $"SELECT data FROM {Table.Post} WHERE data -> '{nameof Post.Empty.CategoryIds}' @> @id" [ "@id", Query.jsonbDocParam [| string catId |] ] fromData if not (List.isEmpty posts) then let! _ = diff --git a/src/MyWebLog.Data/Postgres/PostgresPageData.fs b/src/MyWebLog.Data/Postgres/PostgresPageData.fs index 7bf8c80..766fd14 100644 --- a/src/MyWebLog.Data/Postgres/PostgresPageData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresPageData.fs @@ -37,7 +37,7 @@ type PostgresPageData (log: ILogger) = /// Get all pages for a web log (without text or revisions) let all webLogId = log.LogTrace "Page.all" - Custom.list $"{selectWithCriteria Table.Page} ORDER BY LOWER(data ->> '{nameof Page.empty.Title}')" + Custom.list $"{selectWithCriteria Table.Page} ORDER BY LOWER(data ->> '{nameof Page.Empty.Title}')" [ webLogContains webLogId ] fromData /// Count all pages for the given web log @@ -86,10 +86,10 @@ type PostgresPageData (log: ILogger) = log.LogTrace "Page.findCurrentPermalink" if List.isEmpty permalinks then return None else - let linkSql, linkParam = arrayContains (nameof Page.empty.PriorPermalinks) string permalinks + let linkSql, linkParam = arrayContains (nameof Page.Empty.PriorPermalinks) string permalinks return! Custom.single - $"""SELECT data ->> '{nameof Page.empty.Permalink}' AS permalink + $"""SELECT data ->> '{nameof Page.Empty.Permalink}' AS permalink FROM page WHERE {Query.whereDataContains "@criteria"} AND {linkSql}""" [ webLogContains webLogId; linkParam ] Map.toPermalink @@ -109,7 +109,7 @@ type PostgresPageData (log: ILogger) = /// Get all listed pages for the given web log (without revisions or text) let findListed webLogId = log.LogTrace "Page.findListed" - Custom.list $"{selectWithCriteria Table.Page} ORDER BY LOWER(data ->> '{nameof Page.empty.Title}')" + Custom.list $"{selectWithCriteria Table.Page} ORDER BY LOWER(data ->> '{nameof Page.Empty.Title}')" [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with IsInPageList = true |} ] pageWithoutText @@ -118,7 +118,7 @@ type PostgresPageData (log: ILogger) = log.LogTrace "Page.findPageOfPages" Custom.list $"{selectWithCriteria Table.Page} - ORDER BY LOWER(data->>'{nameof Page.empty.Title}') + ORDER BY LOWER(data->>'{nameof Page.Empty.Title}') LIMIT @pageSize OFFSET @toSkip" [ webLogContains webLogId; "@pageSize", Sql.int 26; "@toSkip", Sql.int ((pageNbr - 1) * 25) ] fromData diff --git a/src/MyWebLog.Data/Postgres/PostgresPostData.fs b/src/MyWebLog.Data/Postgres/PostgresPostData.fs index 7984d35..3ccef17 100644 --- a/src/MyWebLog.Data/Postgres/PostgresPostData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresPostData.fs @@ -80,10 +80,10 @@ type PostgresPostData(log: ILogger) = log.LogTrace "Post.findCurrentPermalink" if List.isEmpty permalinks then return None else - let linkSql, linkParam = arrayContains (nameof Post.empty.PriorPermalinks) string permalinks + let linkSql, linkParam = arrayContains (nameof Post.Empty.PriorPermalinks) string permalinks return! Custom.single - $"""SELECT data ->> '{nameof Post.empty.Permalink}' AS permalink + $"""SELECT data ->> '{nameof Post.Empty.Permalink}' AS permalink FROM {Table.Post} WHERE {Query.whereDataContains "@criteria"} AND {linkSql}""" [ webLogContains webLogId; linkParam ] Map.toPermalink @@ -103,11 +103,11 @@ type PostgresPostData(log: ILogger) = /// Get a page of categorized posts for the given web log (excludes revisions) let findPageOfCategorizedPosts webLogId (categoryIds: CategoryId list) pageNbr postsPerPage = log.LogTrace "Post.findPageOfCategorizedPosts" - let catSql, catParam = arrayContains (nameof Post.empty.CategoryIds) string categoryIds + let catSql, catParam = arrayContains (nameof Post.Empty.CategoryIds) string categoryIds Custom.list $"{selectWithCriteria Table.Post} AND {catSql} - ORDER BY data ->> '{nameof Post.empty.PublishedOn}' DESC + ORDER BY data ->> '{nameof Post.Empty.PublishedOn}' DESC LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}" [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = Published |} catParam @@ -118,8 +118,8 @@ type PostgresPostData(log: ILogger) = log.LogTrace "Post.findPageOfPosts" Custom.list $"{selectWithCriteria Table.Post} - ORDER BY data ->> '{nameof Post.empty.PublishedOn}' DESC NULLS FIRST, - data ->> '{nameof Post.empty.UpdatedOn}' + ORDER BY data ->> '{nameof Post.Empty.PublishedOn}' DESC NULLS FIRST, + data ->> '{nameof Post.Empty.UpdatedOn}' LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}" [ webLogContains webLogId ] postWithoutText @@ -128,7 +128,7 @@ type PostgresPostData(log: ILogger) = log.LogTrace "Post.findPageOfPublishedPosts" Custom.list $"{selectWithCriteria Table.Post} - ORDER BY data ->> '{nameof Post.empty.PublishedOn}' DESC + ORDER BY data ->> '{nameof Post.Empty.PublishedOn}' DESC LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}" [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = Published |} ] fromData @@ -138,8 +138,8 @@ type PostgresPostData(log: ILogger) = log.LogTrace "Post.findPageOfTaggedPosts" Custom.list $"{selectWithCriteria Table.Post} - AND data['{nameof Post.empty.Tags}'] @> @tag - ORDER BY data ->> '{nameof Post.empty.PublishedOn}' DESC + AND data['{nameof Post.Empty.Tags}'] @> @tag + ORDER BY data ->> '{nameof Post.Empty.PublishedOn}' DESC LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}" [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = Published |} "@tag", Query.jsonbDocParam [| tag |] @@ -152,7 +152,7 @@ type PostgresPostData(log: ILogger) = "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = Published |} "@publishedOn", Sql.string ((InstantPattern.General.Format publishedOn)[..19]) ] - let pubField = nameof Post.empty.PublishedOn + let pubField = nameof Post.Empty.PublishedOn let! older = Custom.list $"{selectWithCriteria Table.Post} diff --git a/src/MyWebLog.Data/Postgres/PostgresTagMapData.fs b/src/MyWebLog.Data/Postgres/PostgresTagMapData.fs index 100523a..c252f7b 100644 --- a/src/MyWebLog.Data/Postgres/PostgresTagMapData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresTagMapData.fs @@ -40,7 +40,7 @@ type PostgresTagMapData (log : ILogger) = /// Find any tag mappings in a list of tags for the given web log let findMappingForTags tags webLogId = log.LogTrace "TagMap.findMappingForTags" - let tagSql, tagParam = arrayContains (nameof TagMap.empty.Tag) id tags + let tagSql, tagParam = arrayContains (nameof TagMap.Empty.Tag) id tags Custom.list $"{selectWithCriteria Table.TagMap} AND {tagSql}" [ webLogContains webLogId; tagParam ] fromData diff --git a/src/MyWebLog.Data/Postgres/PostgresWebLogData.fs b/src/MyWebLog.Data/Postgres/PostgresWebLogData.fs index 0efc85d..7015724 100644 --- a/src/MyWebLog.Data/Postgres/PostgresWebLogData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresWebLogData.fs @@ -23,7 +23,7 @@ type PostgresWebLogData (log : ILogger) = log.LogTrace "WebLog.delete" Custom.nonQuery $"""DELETE FROM {Table.PostComment} - WHERE data ->> '{nameof Comment.empty.PostId}' IN + WHERE data ->> '{nameof Comment.Empty.PostId}' IN (SELECT id FROM {Table.Post} WHERE {Query.whereDataContains "@criteria"}); {Query.Delete.byContains Table.Post}; {Query.Delete.byContains Table.Page}; diff --git a/src/MyWebLog.Data/Postgres/PostgresWebLogUserData.fs b/src/MyWebLog.Data/Postgres/PostgresWebLogUserData.fs index dba0985..22090ce 100644 --- a/src/MyWebLog.Data/Postgres/PostgresWebLogUserData.fs +++ b/src/MyWebLog.Data/Postgres/PostgresWebLogUserData.fs @@ -45,7 +45,7 @@ type PostgresWebLogUserData (log : ILogger) = let findByWebLog webLogId = log.LogTrace "WebLogUser.findByWebLog" Custom.list - $"{selectWithCriteria Table.WebLogUser} ORDER BY LOWER(data->>'{nameof WebLogUser.empty.PreferredName}')" + $"{selectWithCriteria Table.WebLogUser} ORDER BY LOWER(data->>'{nameof WebLogUser.Empty.PreferredName}')" [ webLogContains webLogId ] fromData /// Find the names of users by their IDs for the given web log @@ -55,7 +55,7 @@ type PostgresWebLogUserData (log : ILogger) = let! users = Custom.list $"{selectWithCriteria Table.WebLogUser} {idSql}" (webLogContains webLogId :: idParams) fromData - return users |> List.map (fun u -> { Name = string u.Id; Value = WebLogUser.displayName u }) + return users |> List.map (fun u -> { Name = string u.Id; Value = u.DisplayName }) } /// Restore users from a backup diff --git a/src/MyWebLog.Data/PostgresData.fs b/src/MyWebLog.Data/PostgresData.fs index 0b788b0..1c01195 100644 --- a/src/MyWebLog.Data/PostgresData.fs +++ b/src/MyWebLog.Data/PostgresData.fs @@ -58,10 +58,10 @@ type PostgresData (log : ILogger, ser : JsonSerializer) = // Page tables if needsTable Table.Page then Definition.createTable Table.Page - $"CREATE INDEX page_web_log_idx ON {Table.Page} ((data ->> '{nameof Page.empty.WebLogId}'))" - $"CREATE INDEX page_author_idx ON {Table.Page} ((data ->> '{nameof Page.empty.AuthorId}'))" + $"CREATE INDEX page_web_log_idx ON {Table.Page} ((data ->> '{nameof Page.Empty.WebLogId}'))" + $"CREATE INDEX page_author_idx ON {Table.Page} ((data ->> '{nameof Page.Empty.AuthorId}'))" $"CREATE INDEX page_permalink_idx ON {Table.Page} - ((data ->> '{nameof Page.empty.WebLogId}'), (data ->> '{nameof Page.empty.Permalink}'))" + ((data ->> '{nameof Page.Empty.WebLogId}'), (data ->> '{nameof Page.Empty.Permalink}'))" if needsTable Table.PageRevision then $"CREATE TABLE {Table.PageRevision} ( page_id TEXT NOT NULL REFERENCES {Table.Page} (id) ON DELETE CASCADE, @@ -72,15 +72,15 @@ type PostgresData (log : ILogger, ser : JsonSerializer) = // Post tables if needsTable Table.Post then Definition.createTable Table.Post - $"CREATE INDEX post_web_log_idx ON {Table.Post} ((data ->> '{nameof Post.empty.WebLogId}'))" - $"CREATE INDEX post_author_idx ON {Table.Post} ((data ->> '{nameof Post.empty.AuthorId}'))" + $"CREATE INDEX post_web_log_idx ON {Table.Post} ((data ->> '{nameof Post.Empty.WebLogId}'))" + $"CREATE INDEX post_author_idx ON {Table.Post} ((data ->> '{nameof Post.Empty.AuthorId}'))" $"CREATE INDEX post_status_idx ON {Table.Post} - ((data ->> '{nameof Post.empty.WebLogId}'), (data ->> '{nameof Post.empty.Status}'), - (data ->> '{nameof Post.empty.UpdatedOn}'))" + ((data ->> '{nameof Post.Empty.WebLogId}'), (data ->> '{nameof Post.Empty.Status}'), + (data ->> '{nameof Post.Empty.UpdatedOn}'))" $"CREATE INDEX post_permalink_idx ON {Table.Post} - ((data ->> '{nameof Post.empty.WebLogId}'), (data ->> '{nameof Post.empty.Permalink}'))" - $"CREATE INDEX post_category_idx ON {Table.Post} USING GIN ((data['{nameof Post.empty.CategoryIds}']))" - $"CREATE INDEX post_tag_idx ON {Table.Post} USING GIN ((data['{nameof Post.empty.Tags}']))" + ((data ->> '{nameof Post.Empty.WebLogId}'), (data ->> '{nameof Post.Empty.Permalink}'))" + $"CREATE INDEX post_category_idx ON {Table.Post} USING GIN ((data['{nameof Post.Empty.CategoryIds}']))" + $"CREATE INDEX post_tag_idx ON {Table.Post} USING GIN ((data['{nameof Post.Empty.Tags}']))" if needsTable Table.PostRevision then $"CREATE TABLE {Table.PostRevision} ( post_id TEXT NOT NULL REFERENCES {Table.Post} (id) ON DELETE CASCADE, @@ -90,7 +90,7 @@ type PostgresData (log : ILogger, ser : JsonSerializer) = if needsTable Table.PostComment then Definition.createTable Table.PostComment $"CREATE INDEX post_comment_post_idx ON {Table.PostComment} - ((data ->> '{nameof Comment.empty.PostId}'))" + ((data ->> '{nameof Comment.Empty.PostId}'))" // Tag map table if needsTable Table.TagMap then diff --git a/src/MyWebLog.Data/RethinkDbData.fs b/src/MyWebLog.Data/RethinkDbData.fs index acda3a6..068b2b1 100644 --- a/src/MyWebLog.Data/RethinkDbData.fs +++ b/src/MyWebLog.Data/RethinkDbData.fs @@ -97,11 +97,11 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger row[nameof ThemeAsset.empty.Id].Match keyPrefix :> obj + fun (row : Ast.ReqlExpr) -> row[nameof ThemeAsset.Empty.Id].Match keyPrefix :> obj /// Function to exclude template text from themes let withoutTemplateText (row : Ast.ReqlExpr) : obj = - {| Templates = row[nameof Theme.empty.Templates].Without [| nameof ThemeTemplate.Empty.Text |] |} + {| Templates = row[nameof Theme.Empty.Templates].Without [| nameof ThemeTemplate.Empty.Text |] |} /// Ensure field indexes exist, as well as special indexes for selected tables let ensureIndexes table fields = backgroundTask { @@ -112,27 +112,27 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger List.contains table then - let permalinkIdx = nameof Page.empty.Permalink + let permalinkIdx = nameof Page.Empty.Permalink if not (indexes |> List.contains permalinkIdx) then log.LogInformation $"Creating index {table}.{permalinkIdx}..." do! rethink { withTable table indexCreate permalinkIdx - (fun row -> r.Array (row[nameof Page.empty.WebLogId], row[permalinkIdx].Downcase ()) :> obj) + (fun row -> r.Array(row[nameof Page.Empty.WebLogId], row[permalinkIdx].Downcase()) :> obj) write; withRetryOnce; ignoreResult conn } // Prior permalinks are searched when a post or page permalink do not match the current URL - let priorIdx = nameof Post.empty.PriorPermalinks + let priorIdx = nameof Post.Empty.PriorPermalinks if not (indexes |> List.contains priorIdx) then log.LogInformation $"Creating index {table}.{priorIdx}..." do! rethink { withTable table - indexCreate priorIdx (fun row -> row[priorIdx].Downcase () :> obj) [ Multi ] + indexCreate priorIdx (fun row -> row[priorIdx].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 [ nameof Post.empty.CategoryIds; nameof Post.empty.Tags ] do + for idx in [ nameof Post.Empty.CategoryIds; nameof Post.Empty.Tags ] do if not (List.contains idx indexes) then log.LogInformation $"Creating index {table}.{idx}..." do! rethink { @@ -147,7 +147,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger - [| row[nameof TagMap.empty.WebLogId]; row[nameof TagMap.empty.Tag] |] :> obj) + [| row[nameof TagMap.Empty.WebLogId]; row[nameof TagMap.Empty.Tag] |] :> obj) write; withRetryOnce; ignoreResult conn } if not (indexes |> List.contains Index.WebLogAndUrl) then @@ -155,7 +155,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger - [| row[nameof TagMap.empty.WebLogId]; row[nameof TagMap.empty.UrlValue] |] :> obj) + [| row[nameof TagMap.Empty.WebLogId]; row[nameof TagMap.Empty.UrlValue] |] :> obj) write; withRetryOnce; ignoreResult conn } // Uploaded files need an index by web log ID and path, as that is how they are retrieved @@ -165,7 +165,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger - [| row[nameof Upload.empty.WebLogId]; row[nameof Upload.empty.Path] |] :> obj) + [| row[nameof Upload.Empty.WebLogId]; row[nameof Upload.Empty.Path] |] :> obj) write; withRetryOnce; ignoreResult conn } // Users log on with e-mail @@ -175,7 +175,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger - [| row[nameof WebLogUser.empty.WebLogId]; row[nameof WebLogUser.empty.Email] |] :> obj) + [| row[nameof WebLogUser.Empty.WebLogId]; row[nameof WebLogUser.Empty.Email] |] :> obj) write; withRetryOnce; ignoreResult conn } } @@ -226,7 +226,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger obj ] + update [ nameof WebLog.Empty.RedirectRules, [] :> obj ] write; withRetryOnce; ignoreResult conn } @@ -271,15 +271,15 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Category - getAll [ webLogId ] (nameof Category.empty.WebLogId) + getAll [ webLogId ] (nameof Category.Empty.WebLogId) count result; withRetryDefault conn } member _.CountTopLevel webLogId = rethink { withTable Table.Category - getAll [ webLogId ] (nameof Category.empty.WebLogId) - filter (nameof Category.empty.ParentId) None + getAll [ webLogId ] (nameof Category.Empty.WebLogId) + filter (nameof Category.Empty.ParentId) None count result; withRetryDefault conn } @@ -287,8 +287,8 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Category - getAll [ webLogId ] (nameof Category.empty.WebLogId) - orderByFunc (fun it -> it[nameof Category.empty.Name].Downcase () :> obj) + getAll [ webLogId ] (nameof Category.Empty.WebLogId) + orderByFunc (fun it -> it[nameof Category.Empty.Name].Downcase() :> obj) result; withRetryDefault conn } let ordered = Utils.orderByHierarchy cats None None [] @@ -304,8 +304,8 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger List.ofSeq let! count = rethink { withTable Table.Post - getAll catIds (nameof Post.empty.CategoryIds) - filter (nameof Post.empty.Status) Published + getAll catIds (nameof Post.Empty.CategoryIds) + filter (nameof Post.Empty.Status) Published distinct count result; withRetryDefault conn @@ -335,7 +335,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Category - getAll [ webLogId ] (nameof Category.empty.WebLogId) + getAll [ webLogId ] (nameof Category.Empty.WebLogId) result; withRetryDefault conn } @@ -345,24 +345,24 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Category - filter (nameof Category.empty.ParentId) catId + filter (nameof Category.Empty.ParentId) catId count result; withRetryDefault conn } if children > 0 then do! rethink { withTable Table.Category - filter (nameof Category.empty.ParentId) catId - update [ nameof Category.empty.ParentId, cat.ParentId :> obj ] + filter (nameof Category.Empty.ParentId) catId + update [ nameof Category.Empty.ParentId, cat.ParentId :> obj ] write; withRetryDefault; ignoreResult conn } // Delete the category off all posts where it is assigned do! rethink { withTable Table.Post - getAll [ webLogId ] (nameof Post.empty.WebLogId) - filter (fun row -> row[nameof Post.empty.CategoryIds].Contains catId :> obj) + getAll [ webLogId ] (nameof Post.Empty.WebLogId) + filter (fun row -> row[nameof Post.Empty.CategoryIds].Contains catId :> obj) update (fun row -> - {| CategoryIds = r.Array(row[nameof Post.empty.CategoryIds]).Remove catId |} :> obj) + {| CategoryIds = r.Array(row[nameof Post.Empty.CategoryIds]).Remove catId |} :> obj) write; withRetryDefault; ignoreResult conn } // Delete the category itself @@ -408,26 +408,26 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Page - getAll [ webLogId ] (nameof Page.empty.WebLogId) - without [ nameof Page.empty.Text - nameof Page.empty.Metadata - nameof Page.empty.Revisions - nameof Page.empty.PriorPermalinks ] - orderByFunc (fun row -> row[nameof Page.empty.Title].Downcase () :> obj) + getAll [ webLogId ] (nameof Page.Empty.WebLogId) + without [ nameof Page.Empty.Text + nameof Page.Empty.Metadata + nameof Page.Empty.Revisions + nameof Page.Empty.PriorPermalinks ] + orderByFunc (fun row -> row[nameof Page.Empty.Title].Downcase() :> obj) result; withRetryDefault conn } member _.CountAll webLogId = rethink { withTable Table.Page - getAll [ webLogId ] (nameof Page.empty.WebLogId) + getAll [ webLogId ] (nameof Page.Empty.WebLogId) count result; withRetryDefault conn } member _.CountListed webLogId = rethink { withTable Table.Page - getAll [ webLogId ] (nameof Page.empty.WebLogId) - filter (nameof Page.empty.IsInPageList) true + getAll [ webLogId ] (nameof Page.Empty.WebLogId) + filter (nameof Page.Empty.IsInPageList) true count result; withRetryDefault conn } @@ -436,7 +436,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Page getAll [ pageId ] - filter (fun row -> row[nameof Page.empty.WebLogId].Eq webLogId :> obj) + filter (fun row -> row[nameof Page.Empty.WebLogId].Eq webLogId :> obj) delete write; withRetryDefault conn } @@ -447,7 +447,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Page get pageId - without [ nameof Page.empty.PriorPermalinks; nameof Page.empty.Revisions ] + without [ nameof Page.Empty.PriorPermalinks; nameof Page.Empty.Revisions ] resultOption; withRetryOptionDefault } |> verifyWebLog webLogId (fun it -> it.WebLogId) <| conn @@ -455,8 +455,8 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Page - getAll [ [| webLogId :> obj; permalink |] ] (nameof Page.empty.Permalink) - without [ nameof Page.empty.PriorPermalinks; nameof Page.empty.Revisions ] + getAll [ [| webLogId :> obj; permalink |] ] (nameof Page.Empty.Permalink) + without [ nameof Page.Empty.PriorPermalinks; nameof Page.Empty.Revisions ] limit 1 result; withRetryDefault } @@ -466,9 +466,9 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Page - getAll (objList permalinks) (nameof Page.empty.PriorPermalinks) - filter (nameof Page.empty.WebLogId) webLogId - without [ nameof Page.empty.Revisions; nameof Page.empty.Text ] + getAll (objList permalinks) (nameof Page.Empty.PriorPermalinks) + filter (nameof Page.Empty.WebLogId) webLogId + without [ nameof Page.Empty.Revisions; nameof Page.Empty.Text ] limit 1 result; withRetryDefault } @@ -486,26 +486,26 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Page - getAll [ webLogId ] (nameof Page.empty.WebLogId) + getAll [ webLogId ] (nameof Page.Empty.WebLogId) resultCursor; withRetryCursorDefault; toList conn } member _.FindListed webLogId = rethink { withTable Table.Page - getAll [ webLogId ] (nameof Page.empty.WebLogId) - filter [ nameof Page.empty.IsInPageList, true :> obj ] - without [ nameof Page.empty.Text; nameof Page.empty.PriorPermalinks; nameof Page.empty.Revisions ] - orderBy (nameof Page.empty.Title) + getAll [ webLogId ] (nameof Page.Empty.WebLogId) + filter [ nameof Page.Empty.IsInPageList, true :> obj ] + without [ nameof Page.Empty.Text; nameof Page.Empty.PriorPermalinks; nameof Page.Empty.Revisions ] + orderBy (nameof Page.Empty.Title) result; withRetryDefault conn } member _.FindPageOfPages webLogId pageNbr = rethink { withTable Table.Page - getAll [ webLogId ] (nameof Page.empty.WebLogId) - without [ nameof Page.empty.Metadata - nameof Page.empty.PriorPermalinks - nameof Page.empty.Revisions ] - orderByFunc (fun row -> row[nameof Page.empty.Title].Downcase ()) + getAll [ webLogId ] (nameof Page.Empty.WebLogId) + without [ nameof Page.Empty.Metadata + nameof Page.Empty.PriorPermalinks + nameof Page.Empty.Revisions ] + orderByFunc (fun row -> row[nameof Page.Empty.Title].Downcase()) skip ((pageNbr - 1) * 25) limit 25 result; withRetryDefault conn @@ -543,7 +543,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger obj ] + update [ nameof Page.Empty.PriorPermalinks, permalinks :> obj ] write; withRetryDefault; ignoreResult conn } return true @@ -562,8 +562,8 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Post - getAll [ webLogId ] (nameof Post.empty.WebLogId) - filter (nameof Post.empty.Status) status + getAll [ webLogId ] (nameof Post.Empty.WebLogId) + filter (nameof Post.Empty.Status) status count result; withRetryDefault conn } @@ -572,7 +572,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Post getAll [ postId ] - filter (fun row -> row[nameof Post.empty.WebLogId].Eq webLogId :> obj) + filter (fun row -> row[nameof Post.Empty.WebLogId].Eq webLogId :> obj) delete write; withRetryDefault conn } @@ -583,7 +583,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Post get postId - without [ nameof Post.empty.PriorPermalinks; nameof Post.empty.Revisions ] + without [ nameof Post.Empty.PriorPermalinks; nameof Post.Empty.Revisions ] resultOption; withRetryOptionDefault } |> verifyWebLog webLogId (fun p -> p.WebLogId) <| conn @@ -591,8 +591,8 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Post - getAll [ [| webLogId :> obj; permalink |] ] (nameof Post.empty.Permalink) - without [ nameof Post.empty.PriorPermalinks; nameof Post.empty.Revisions ] + getAll [ [| webLogId :> obj; permalink |] ] (nameof Post.Empty.Permalink) + without [ nameof Post.Empty.PriorPermalinks; nameof Post.Empty.Revisions ] limit 1 result; withRetryDefault } @@ -610,9 +610,9 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Post - getAll (objList permalinks) (nameof Post.empty.PriorPermalinks) - filter (nameof Post.empty.WebLogId) webLogId - without [ nameof Post.empty.Revisions; nameof Post.empty.Text ] + getAll (objList permalinks) (nameof Post.Empty.PriorPermalinks) + filter (nameof Post.Empty.WebLogId) webLogId + without [ nameof Post.Empty.Revisions; nameof Post.Empty.Text ] limit 1 result; withRetryDefault } @@ -622,18 +622,18 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Post - getAll [ webLogId ] (nameof Post.empty.WebLogId) + getAll [ webLogId ] (nameof Post.Empty.WebLogId) resultCursor; withRetryCursorDefault; toList conn } member _.FindPageOfCategorizedPosts webLogId categoryIds pageNbr postsPerPage = rethink { withTable Table.Post - getAll (objList categoryIds) (nameof Post.empty.CategoryIds) - filter [ nameof Post.empty.WebLogId, webLogId :> obj - nameof Post.empty.Status, Published ] - without [ nameof Post.empty.PriorPermalinks; nameof Post.empty.Revisions ] + getAll (objList categoryIds) (nameof Post.Empty.CategoryIds) + filter [ nameof Post.Empty.WebLogId, webLogId :> obj + nameof Post.Empty.Status, Published ] + without [ nameof Post.Empty.PriorPermalinks; nameof Post.Empty.Revisions ] distinct - orderByDescending (nameof Post.empty.PublishedOn) + orderByDescending (nameof Post.Empty.PublishedOn) skip ((pageNbr - 1) * postsPerPage) limit (postsPerPage + 1) result; withRetryDefault conn @@ -641,10 +641,10 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Post - getAll [ webLogId ] (nameof Post.empty.WebLogId) - without [ nameof Post.empty.PriorPermalinks; nameof Post.empty.Revisions ] + getAll [ webLogId ] (nameof Post.Empty.WebLogId) + without [ nameof Post.Empty.PriorPermalinks; nameof Post.Empty.Revisions ] orderByFuncDescending (fun row -> - row[nameof Post.empty.PublishedOn].Default_ (nameof Post.empty.UpdatedOn) :> obj) + row[nameof Post.Empty.PublishedOn].Default_(nameof Post.Empty.UpdatedOn) :> obj) skip ((pageNbr - 1) * postsPerPage) limit (postsPerPage + 1) result; withRetryDefault conn @@ -652,10 +652,10 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Post - getAll [ webLogId ] (nameof Post.empty.WebLogId) - filter (nameof Post.empty.Status) Published - without [ nameof Post.empty.PriorPermalinks; nameof Post.empty.Revisions ] - orderByDescending (nameof Post.empty.PublishedOn) + getAll [ webLogId ] (nameof Post.Empty.WebLogId) + filter (nameof Post.Empty.Status) Published + without [ nameof Post.Empty.PriorPermalinks; nameof Post.Empty.Revisions ] + orderByDescending (nameof Post.Empty.PublishedOn) skip ((pageNbr - 1) * postsPerPage) limit (postsPerPage + 1) result; withRetryDefault conn @@ -663,11 +663,11 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Post - getAll [ tag ] (nameof Post.empty.Tags) - filter [ nameof Post.empty.WebLogId, webLogId :> obj - nameof Post.empty.Status, Published ] - without [ nameof Post.empty.PriorPermalinks; nameof Post.empty.Revisions ] - orderByDescending (nameof Post.empty.PublishedOn) + getAll [ tag ] (nameof Post.Empty.Tags) + filter [ nameof Post.Empty.WebLogId, webLogId :> obj + nameof Post.Empty.Status, Published ] + without [ nameof Post.Empty.PriorPermalinks; nameof Post.Empty.Revisions ] + orderByDescending (nameof Post.Empty.PublishedOn) skip ((pageNbr - 1) * postsPerPage) limit (postsPerPage + 1) result; withRetryDefault conn @@ -677,10 +677,10 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Post - getAll [ webLogId ] (nameof Post.empty.WebLogId) - filter (fun row -> row[nameof Post.empty.PublishedOn].Lt publishedOn :> obj) - without [ nameof Post.empty.PriorPermalinks; nameof Post.empty.Revisions ] - orderByDescending (nameof Post.empty.PublishedOn) + getAll [ webLogId ] (nameof Post.Empty.WebLogId) + filter (fun row -> row[nameof Post.Empty.PublishedOn].Lt publishedOn :> obj) + without [ nameof Post.Empty.PriorPermalinks; nameof Post.Empty.Revisions ] + orderByDescending (nameof Post.Empty.PublishedOn) limit 1 result; withRetryDefault } @@ -688,10 +688,10 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Post - getAll [ webLogId ] (nameof Post.empty.WebLogId) - filter (fun row -> row[nameof Post.empty.PublishedOn].Gt publishedOn :> obj) - without [ nameof Post.empty.PriorPermalinks; nameof Post.empty.Revisions ] - orderBy (nameof Post.empty.PublishedOn) + getAll [ webLogId ] (nameof Post.Empty.WebLogId) + filter (fun row -> row[nameof Post.Empty.PublishedOn].Gt publishedOn :> obj) + without [ nameof Post.Empty.PriorPermalinks; nameof Post.Empty.Revisions ] + orderBy (nameof Post.Empty.PublishedOn) limit 1 result; withRetryDefault } @@ -720,15 +720,15 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Post get postId - without [ nameof Post.empty.Revisions; nameof Post.empty.PriorPermalinks ] + without [ nameof Post.Empty.Revisions; nameof Post.Empty.PriorPermalinks ] resultOption; withRetryOptionDefault } - |> verifyWebLog webLogId (fun p -> p.WebLogId)) conn with + |> verifyWebLog webLogId (_.WebLogId)) conn with | Some _ -> do! rethink { withTable Table.Post get postId - update [ nameof Post.empty.PriorPermalinks, permalinks :> obj ] + update [ nameof Post.Empty.PriorPermalinks, permalinks :> obj ] write; withRetryDefault; ignoreResult conn } return true @@ -743,7 +743,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.TagMap getAll [ tagMapId ] - filter (fun row -> row[nameof TagMap.empty.WebLogId].Eq webLogId :> obj) + filter (fun row -> row[nameof TagMap.Empty.WebLogId].Eq webLogId :> obj) delete write; withRetryDefault conn } @@ -756,7 +756,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger verifyWebLog webLogId (fun tm -> tm.WebLogId) <| conn + |> verifyWebLog webLogId (_.WebLogId) <| conn member _.FindByUrlValue urlValue webLogId = rethink { @@ -769,9 +769,9 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.TagMap - between [| webLogId :> obj; r.Minval () |] [| webLogId :> obj; r.Maxval () |] + between [| webLogId :> obj; r.Minval() |] [| webLogId :> obj; r.Maxval() |] [ Index Index.WebLogAndTag ] - orderBy (nameof TagMap.empty.Tag) + orderBy (nameof TagMap.Empty.Tag) result; withRetryDefault conn } @@ -803,16 +803,16 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Theme - filter (fun row -> row[nameof Theme.empty.Id].Ne "admin" :> obj) + filter (fun row -> row[nameof Theme.Empty.Id].Ne "admin" :> obj) merge withoutTemplateText - orderBy (nameof Theme.empty.Id) + orderBy (nameof Theme.Empty.Id) result; withRetryDefault conn } member _.Exists themeId = backgroundTask { let! count = rethink { withTable Table.Theme - filter (nameof Theme.empty.Id) themeId + filter (nameof Theme.Empty.Id) themeId count result; withRetryDefault conn } @@ -859,7 +859,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.ThemeAsset - without [ nameof ThemeAsset.empty.Data ] + without [ nameof ThemeAsset.Empty.Data ] result; withRetryDefault conn } @@ -874,7 +874,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.ThemeAsset filter (matchAssetByThemeId themeId) - without [ nameof ThemeAsset.empty.Data ] + without [ nameof ThemeAsset.Empty.Data ] result; withRetryDefault conn } @@ -931,9 +931,9 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Upload - between [| webLogId :> obj; r.Minval () |] [| webLogId :> obj; r.Maxval () |] + between [| webLogId :> obj; r.Minval() |] [| webLogId :> obj; r.Maxval() |] [ Index Index.WebLogAndPath ] - without [ nameof Upload.empty.Data ] + without [ nameof Upload.Empty.Data ] resultCursor; withRetryCursorDefault; toList conn } @@ -973,22 +973,22 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.Post - getAll [ webLogId ] (nameof Post.empty.WebLogId) - pluck [ nameof Post.empty.Id ] + getAll [ webLogId ] (nameof Post.Empty.WebLogId) + pluck [ nameof Post.Empty.Id ] result; withRetryOnce conn } if not (List.isEmpty thePostIds) then let postIds = thePostIds |> List.map (fun it -> it.Id :> obj) do! rethink { withTable Table.Comment - getAll postIds (nameof Comment.empty.PostId) + getAll postIds (nameof Comment.Empty.PostId) delete write; withRetryOnce; ignoreResult conn } // Tag mappings do not have a straightforward webLogId index do! rethink { withTable Table.TagMap - between [| webLogId :> obj; r.Minval () |] [| webLogId :> obj; r.Maxval () |] + between [| webLogId :> obj; r.Minval() |] [| webLogId :> obj; r.Maxval() |] [ Index Index.WebLogAndTag ] delete write; withRetryOnce; ignoreResult conn @@ -996,7 +996,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger obj; r.Minval () |] [| webLogId :> obj; r.Maxval () |] + between [| webLogId :> obj; r.Minval() |] [| webLogId :> obj; r.Maxval() |] [ Index Index.WebLogAndPath ] delete write; withRetryOnce; ignoreResult conn @@ -1004,7 +1004,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.WebLog - getAll [ url ] (nameof WebLog.empty.UrlBase) + getAll [ url ] (nameof WebLog.Empty.UrlBase) limit 1 result; withRetryDefault } @@ -1034,14 +1034,14 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger obj ] + update [ nameof WebLog.Empty.RedirectRules, webLog.RedirectRules :> obj ] write; withRetryDefault; ignoreResult conn } member _.UpdateRssOptions webLog = rethink { withTable Table.WebLog get webLog.Id - update [ nameof WebLog.empty.Rss, webLog.Rss :> obj ] + update [ nameof WebLog.Empty.Rss, webLog.Rss :> obj ] write; withRetryDefault; ignoreResult conn } @@ -1085,15 +1085,15 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger let! pageCount = rethink { withTable Table.Page - getAll [ webLogId ] (nameof Page.empty.WebLogId) - filter (nameof Page.empty.AuthorId) userId + getAll [ webLogId ] (nameof Page.Empty.WebLogId) + filter (nameof Page.Empty.AuthorId) userId count result; withRetryDefault conn } let! postCount = rethink { withTable Table.Post - getAll [ webLogId ] (nameof Post.empty.WebLogId) - filter (nameof Post.empty.AuthorId) userId + getAll [ webLogId ] (nameof Post.Empty.WebLogId) + filter (nameof Post.Empty.AuthorId) userId count result; withRetryDefault conn } @@ -1121,8 +1121,8 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.WebLogUser - getAll [ webLogId ] (nameof WebLogUser.empty.WebLogId) - orderByFunc (fun row -> row[nameof WebLogUser.empty.PreferredName].Downcase ()) + getAll [ webLogId ] (nameof WebLogUser.Empty.WebLogId) + orderByFunc (fun row -> row[nameof WebLogUser.Empty.PreferredName].Downcase()) result; withRetryDefault conn } @@ -1130,10 +1130,10 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.WebLogUser getAll (objList userIds) - filter (nameof WebLogUser.empty.WebLogId) webLogId + filter (nameof WebLogUser.Empty.WebLogId) webLogId result; withRetryDefault conn } - return users |> List.map (fun u -> { Name = string u.Id; Value = WebLogUser.displayName u }) + return users |> List.map (fun u -> { Name = string u.Id; Value = u.DisplayName }) } member _.Restore users = backgroundTask { @@ -1151,7 +1151,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger obj ] + update [ nameof WebLogUser.Empty.LastSeenOn, Noda.now () :> obj ] write; withRetryOnce; ignoreResult conn } | None -> () @@ -1196,19 +1196,19 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger { withTable Table.DbVersion limit 1 result; withRetryOnce conn } - do! migrate (List.tryHead version |> Option.map (fun x -> x.Id)) + do! migrate (List.tryHead version |> Option.map _.Id) } diff --git a/src/MyWebLog.Data/SQLite/Helpers.fs b/src/MyWebLog.Data/SQLite/Helpers.fs index b37c7bc..d7031fb 100644 --- a/src/MyWebLog.Data/SQLite/Helpers.fs +++ b/src/MyWebLog.Data/SQLite/Helpers.fs @@ -232,7 +232,7 @@ module Map = /// Create a page from the current row in the given data reader let toPage ser rdr : Page = - { Page.empty with + { Page.Empty with Id = getString "id" rdr |> PageId WebLogId = getString "web_log_id" rdr |> WebLogId AuthorId = getString "author_id" rdr |> WebLogUserId @@ -250,7 +250,7 @@ module Map = /// Create a post from the current row in the given data reader let toPost ser rdr : Post = - { Post.empty with + { Post.Empty with Id = getString "id" rdr |> PostId WebLogId = getString "web_log_id" rdr |> WebLogId AuthorId = getString "author_id" rdr |> WebLogUserId @@ -283,7 +283,7 @@ module Map = /// Create a theme from the current row in the given data reader (excludes templates) let toTheme rdr : Theme = - { Theme.empty with + { Theme.Empty with Id = getString "id" rdr |> ThemeId Name = getString "name" rdr Version = getString "version" rdr diff --git a/src/MyWebLog.Data/SQLite/SQLiteWebLogUserData.fs b/src/MyWebLog.Data/SQLite/SQLiteWebLogUserData.fs index 20a6056..7c0f242 100644 --- a/src/MyWebLog.Data/SQLite/SQLiteWebLogUserData.fs +++ b/src/MyWebLog.Data/SQLite/SQLiteWebLogUserData.fs @@ -96,8 +96,7 @@ type SQLiteWebLogUserData(conn: SqliteConnection) = addWebLogId cmd webLogId cmd.Parameters.AddRange nameParams use! rdr = cmd.ExecuteReaderAsync () - return - toList Map.toWebLogUser rdr |> List.map (fun u -> { Name = string u.Id; Value = WebLogUser.displayName u }) + return toList Map.toWebLogUser rdr |> List.map (fun u -> { Name = string u.Id; Value = u.DisplayName }) } /// Restore users from a backup diff --git a/src/MyWebLog.Domain/DataTypes.fs b/src/MyWebLog.Domain/DataTypes.fs index 01c6a39..413702a 100644 --- a/src/MyWebLog.Domain/DataTypes.fs +++ b/src/MyWebLog.Domain/DataTypes.fs @@ -8,29 +8,26 @@ open NodaTime [] type Category = { /// The ID of the category - Id : CategoryId + Id: CategoryId /// The ID of the web log to which the category belongs - WebLogId : WebLogId + WebLogId: WebLogId /// The displayed name - Name : string + Name: string /// The slug (used in category URLs) - Slug : string + Slug: string /// A longer description of the category - Description : string option + Description: string option /// The parent ID of this category (if a subcategory) - ParentId : CategoryId option -} - -/// Functions to support categories -module Category = + ParentId: CategoryId option +} with /// An empty category - let empty = { + static member Empty = { Id = CategoryId.Empty WebLogId = WebLogId.Empty Name = "" @@ -44,38 +41,35 @@ module Category = [] type Comment = { /// The ID of the comment - Id : CommentId + Id: CommentId /// The ID of the post to which this comment applies - PostId : PostId + PostId: PostId /// The ID of the comment to which this comment is a reply - InReplyToId : CommentId option + InReplyToId: CommentId option /// The name of the commentor - Name : string + Name: string /// The e-mail address of the commentor - Email : string + Email: string /// The URL of the commentor's personal website - Url : string option + Url: string option /// The status of the comment - Status : CommentStatus + Status: CommentStatus /// When the comment was posted - PostedOn : Instant + PostedOn: Instant /// The text of the comment - Text : string -} - -/// Functions to support comments -module Comment = + Text: string +} with /// An empty comment - let empty = { + static member Empty = { Id = CommentId.Empty PostId = PostId.Empty InReplyToId = None @@ -92,50 +86,47 @@ module Comment = [] type Page = { /// The ID of this page - Id : PageId + Id: PageId /// The ID of the web log to which this page belongs - WebLogId : WebLogId + WebLogId: WebLogId /// The ID of the author of this page - AuthorId : WebLogUserId + AuthorId: WebLogUserId /// The title of the page - Title : string + Title: string /// The link at which this page is displayed - Permalink : Permalink + Permalink: Permalink /// When this page was published - PublishedOn : Instant + PublishedOn: Instant /// When this page was last updated - UpdatedOn : Instant + UpdatedOn: Instant /// Whether this page shows as part of the web log's navigation - IsInPageList : bool + IsInPageList: bool /// The template to use when rendering this page - Template : string option + Template: string option /// The current text of the page - Text : string + Text: string /// Metadata for this page - Metadata : MetaItem list + Metadata: MetaItem list /// Permalinks at which this page may have been previously served (useful for migrated content) - PriorPermalinks : Permalink list + PriorPermalinks: Permalink list /// Revisions of this page - Revisions : Revision list -} - -/// Functions to support pages -module Page = + Revisions: Revision list +} with /// An empty page - let empty = { + static member Empty = { Id = PageId.Empty WebLogId = WebLogId.Empty AuthorId = WebLogUserId.Empty @@ -156,59 +147,56 @@ module Page = [] type Post = { /// The ID of this post - Id : PostId + Id: PostId /// The ID of the web log to which this post belongs - WebLogId : WebLogId + WebLogId: WebLogId /// The ID of the author of this post - AuthorId : WebLogUserId + AuthorId: WebLogUserId /// The status - Status : PostStatus + Status: PostStatus /// The title - Title : string + Title: string /// The link at which the post resides - Permalink : Permalink + Permalink: Permalink /// The instant on which the post was originally published - PublishedOn : Instant option + PublishedOn: Instant option /// The instant on which the post was last updated - UpdatedOn : Instant + UpdatedOn: Instant /// The template to use in displaying the post - Template : string option + Template: string option /// The text of the post in HTML (ready to display) format - Text : string + Text: string /// The Ids of the categories to which this is assigned - CategoryIds : CategoryId list + CategoryIds: CategoryId list /// The tags for the post - Tags : string list + Tags: string list /// Podcast episode information for this post - Episode : Episode option + Episode: Episode option /// Metadata for the post - Metadata : MetaItem list + Metadata: MetaItem list /// Permalinks at which this post may have been previously served (useful for migrated content) - PriorPermalinks : Permalink list + PriorPermalinks: Permalink list /// The revisions for this post - Revisions : Revision list -} - -/// Functions to support posts -module Post = + Revisions: Revision list +} with /// An empty post - let empty = { + static member Empty = { Id = PostId.Empty WebLogId = WebLogId.Empty AuthorId = WebLogUserId.Empty @@ -229,25 +217,23 @@ module Post = /// A mapping between a tag and its URL value, used to translate restricted characters (ex. "#1" -> "number-1") +[] type TagMap = { /// The ID of this tag mapping - Id : TagMapId + Id: TagMapId /// The ID of the web log to which this tag mapping belongs - WebLogId : WebLogId + WebLogId: WebLogId /// The tag which should be mapped to a different value in links - Tag : string + Tag: string /// The value by which the tag should be linked - UrlValue : string -} - -/// Functions to support tag mappings -module TagMap = + UrlValue: string +} with /// An empty tag mapping - let empty = { + static member Empty = { Id = TagMapId.Empty WebLogId = WebLogId.Empty Tag = "" @@ -256,26 +242,24 @@ module TagMap = /// A theme +[] type Theme = { /// The ID / path of the theme - Id : ThemeId + Id: ThemeId /// A long name of the theme - Name : string + Name: string /// The version of the theme - Version : string + Version: string /// The templates for this theme Templates: ThemeTemplate list -} - -/// Functions to support themes -module Theme = +} with /// An empty theme - let empty = { - Id = ThemeId "" + static member Empty = { + Id = ThemeId.Empty Name = "" Version = "" Templates = [] @@ -283,51 +267,47 @@ module Theme = /// 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 + Id: ThemeAssetId /// The updated date (set from the file date from the ZIP archive) - UpdatedOn : Instant + UpdatedOn: Instant /// The data for the asset - Data : byte[] -} - -/// Functions to support theme assets -module ThemeAsset = + Data: byte array +} with /// An empty theme asset - let empty = { - Id = ThemeAssetId (ThemeId "", "") + static member Empty = { + Id = ThemeAssetId.Empty UpdatedOn = Noda.epoch Data = [||] } /// An uploaded file +[] type Upload = { /// The ID of the upload - Id : UploadId + Id: UploadId /// The ID of the web log to which this upload belongs - WebLogId : WebLogId + WebLogId: WebLogId /// The link at which this upload is served - Path : Permalink + Path: Permalink /// The updated date/time for this upload - UpdatedOn : Instant + UpdatedOn: Instant /// The data for the upload - Data : byte[] -} - -/// Functions to support uploaded files -module Upload = + Data: byte array +} with /// An empty upload - let empty = { + static member Empty = { Id = UploadId.Empty WebLogId = WebLogId.Empty Path = Permalink.Empty @@ -336,54 +316,53 @@ module Upload = } +open Newtonsoft.Json + /// A web log [] type WebLog = { /// The ID of the web log - Id : WebLogId + Id: WebLogId /// The name of the web log - Name : string + Name: string /// The slug of the web log - Slug : string + Slug: string /// A subtitle for the web log - Subtitle : string option + Subtitle: string option /// The default page ("posts" or a page Id) - DefaultPage : string + DefaultPage: string /// The number of posts to display on pages of posts - PostsPerPage : int + PostsPerPage: int /// The ID of the theme (also the path within /themes) - ThemeId : ThemeId + ThemeId: ThemeId /// The URL base - UrlBase : string + UrlBase: string /// The time zone in which dates/times should be displayed - TimeZone : string + TimeZone: string /// The RSS options for this web log - Rss : RssOptions + Rss: RssOptions /// Whether to automatically load htmx - AutoHtmx : bool + AutoHtmx: bool /// Where uploads are placed - Uploads : UploadDestination + Uploads: UploadDestination /// Redirect rules for this weblog - RedirectRules : RedirectRule list -} - -/// Functions to support web logs -module WebLog = + RedirectRules: RedirectRule list +} with /// An empty web log - let empty = { + static member Empty = { Id = WebLogId.Empty Name = "" Slug = "" @@ -399,24 +378,23 @@ module WebLog = RedirectRules = [] } - /// 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 "" + /// Any extra path where this web log is hosted (blank if web log is hosted at the root of the domain) + [] + member this.ExtraPath = + let path = this.UrlBase.Split("://").[1].Split "/" + if path.Length > 1 then $"""/{String.Join("/", path |> Array.skip 1)}""" else "" /// Generate an absolute URL for the given link - let absoluteUrl webLog (permalink: Permalink) = - $"{webLog.UrlBase}/{permalink}" - + member this.AbsoluteUrl(permalink: Permalink) = + $"{this.UrlBase}/{permalink}" + /// Generate a relative URL for the given link - let relativeUrl webLog (permalink: Permalink) = - let _, leadPath = hostAndPath webLog - $"{leadPath}/{permalink}" + member this.RelativeUrl(permalink: Permalink) = + $"{this.ExtraPath}/{permalink}" /// Convert an Instant (UTC reference) to the web log's local date/time - let localTime webLog (date: Instant) = - match DateTimeZoneProviders.Tzdb[webLog.TimeZone] with + member this.LocalTime(date: Instant) = + match DateTimeZoneProviders.Tzdb[this.TimeZone] with | null -> date.ToDateTimeUtc() | tz -> date.InZone(tz).ToDateTimeUnspecified() @@ -425,44 +403,41 @@ module WebLog = [] type WebLogUser = { /// The ID of the user - Id : WebLogUserId + Id: WebLogUserId /// The ID of the web log to which this user belongs - WebLogId : WebLogId + WebLogId: WebLogId /// The user name (e-mail address) - Email : string + Email: string /// The user's first name - FirstName : string + FirstName: string /// The user's last name - LastName : string + LastName: string /// The user's preferred name - PreferredName : string + PreferredName: string /// The hash of the user's password - PasswordHash : string + PasswordHash: string /// The URL of the user's personal site - Url : string option + Url: string option /// The user's access level - AccessLevel : AccessLevel + AccessLevel: AccessLevel /// When the user was created - CreatedOn : Instant + CreatedOn: Instant /// When the user last logged on - LastSeenOn : Instant option -} - -/// Functions to support web log users -module WebLogUser = + LastSeenOn: Instant option +} with /// An empty web log user - let empty = { + static member Empty = { Id = WebLogUserId.Empty WebLogId = WebLogId.Empty Email = "" @@ -477,12 +452,7 @@ module WebLogUser = } /// 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() - - /// Does a user have the required access level? - let hasAccess level user = - user.AccessLevel.HasAccess level + [] + member this.DisplayName = + (seq { match this.PreferredName with "" -> this.FirstName | n -> n; " "; this.LastName } + |> Seq.reduce (+)).Trim() diff --git a/src/MyWebLog.Domain/MyWebLog.Domain.fsproj b/src/MyWebLog.Domain/MyWebLog.Domain.fsproj index 048dc6c..9460006 100644 --- a/src/MyWebLog.Domain/MyWebLog.Domain.fsproj +++ b/src/MyWebLog.Domain/MyWebLog.Domain.fsproj @@ -9,6 +9,7 @@ + diff --git a/src/MyWebLog.Domain/SupportTypes.fs b/src/MyWebLog.Domain/SupportTypes.fs index ee3c01c..8a9c98b 100644 --- a/src/MyWebLog.Domain/SupportTypes.fs +++ b/src/MyWebLog.Domain/SupportTypes.fs @@ -72,13 +72,11 @@ type AccessLevel = /// Does a given access level allow an action that requires a certain access level? member this.HasAccess(needed: AccessLevel) = - // TODO: Move this to user where it seems to belong better... let weights = [ Author, 10 Editor, 20 WebLogAdmin, 30 - Administrator, 40 - ] + Administrator, 40 ] |> Map.ofList weights[needed] <= weights[this] @@ -639,6 +637,9 @@ type TagMapId = type ThemeId = | ThemeId of string + /// An empty theme ID + static member Empty = ThemeId "" + /// The string representation of a theme ID override this.ToString() = match this with ThemeId it -> it @@ -649,6 +650,9 @@ type ThemeId = type ThemeAssetId = | ThemeAssetId of ThemeId * string + /// An empty theme asset ID + static member Empty = ThemeAssetId(ThemeId.Empty, "") + /// Convert a string into a theme asset ID static member Parse(it : string) = let themeIdx = it.IndexOf "/" diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index 2005de8..d8ec0b4 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -103,46 +103,46 @@ module DisplayCustomFeed = /// Details about a page used to display page lists [] -type DisplayPage = - { /// The ID of this page - Id : string +type DisplayPage = { + /// The ID of this page + Id: string - /// The ID of the author of this page - AuthorId : string - - /// The title of the page - Title : 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 link at which this page is displayed + Permalink: string - /// When this page was published - PublishedOn : DateTime + /// When this page was published + PublishedOn: DateTime - /// When this page was last updated - UpdatedOn : DateTime + /// When this page was last updated + UpdatedOn: DateTime - /// Whether this page shows as part of the web log's navigation - IsInPageList : bool - - /// Is this the default page? - IsDefault : bool - - /// The text of the page - Text : string - - /// The metadata for the page - Metadata : MetaItem list - } + /// Whether this page shows as part of the web log's navigation + IsInPageList: bool + + /// Is this the default page? + IsDefault: bool + + /// The text of the page + Text: string + + /// The metadata for the page + Metadata: MetaItem list +} with /// Create a minimal display page (no text or metadata) from a database page - static member FromPageMinimal webLog (page: Page) = { + static member FromPageMinimal (webLog: WebLog) (page: Page) = { Id = string page.Id AuthorId = string page.AuthorId Title = page.Title Permalink = string page.Permalink - PublishedOn = WebLog.localTime webLog page.PublishedOn - UpdatedOn = WebLog.localTime webLog page.UpdatedOn + PublishedOn = webLog.LocalTime page.PublishedOn + UpdatedOn = webLog.LocalTime page.UpdatedOn IsInPageList = page.IsInPageList IsDefault = string page.Id = webLog.DefaultPage Text = "" @@ -150,18 +150,10 @@ type DisplayPage = } /// Create a display page from a database page - static member FromPage webLog (page : Page) = - let _, extra = WebLog.hostAndPath webLog - { Id = string page.Id - AuthorId = string page.AuthorId - Title = page.Title - Permalink = string page.Permalink - PublishedOn = WebLog.localTime webLog page.PublishedOn - UpdatedOn = WebLog.localTime webLog page.UpdatedOn - IsInPageList = page.IsInPageList - IsDefault = string page.Id = webLog.DefaultPage - Text = addBaseToRelativeUrls extra page.Text - Metadata = page.Metadata + static member FromPage webLog page = + { DisplayPage.FromPageMinimal webLog page with + Text = addBaseToRelativeUrls webLog.ExtraPath page.Text + Metadata = page.Metadata } @@ -169,22 +161,22 @@ type DisplayPage = [] type DisplayRevision = { /// The as-of date/time for the revision - AsOf : DateTime + AsOf: DateTime /// The as-of date/time for the revision in the web log's local time zone - AsOfLocal : DateTime + AsOfLocal: DateTime /// The format of the text of the revision - Format : string + Format: string } /// Functions to support displaying revisions module DisplayRevision = /// Create a display revision from an actual revision - let fromRevision webLog (rev : Revision) = + let fromRevision (webLog: WebLog) (rev : Revision) = { AsOf = rev.AsOf.ToDateTimeUtc () - AsOfLocal = WebLog.localTime webLog rev.AsOf + AsOfLocal = webLog.LocalTime rev.AsOf Format = rev.Text.SourceType } @@ -250,13 +242,13 @@ type DisplayUpload = { module DisplayUpload = /// Create a display uploaded file - let fromUpload webLog (source: UploadDestination) (upload: Upload) = + let fromUpload (webLog: WebLog) (source: UploadDestination) (upload: Upload) = let path = string upload.Path let name = Path.GetFileName path { Id = string upload.Id Name = name Path = path.Replace(name, "") - UpdatedOn = Some (WebLog.localTime webLog upload.UpdatedOn) + UpdatedOn = Some (webLog.LocalTime upload.UpdatedOn) Source = string source } @@ -296,17 +288,17 @@ type DisplayUser = { module DisplayUser = /// Construct a displayed user from a web log user - let fromUser webLog (user: WebLogUser) = - { Id = string user.Id - Email = user.Email - FirstName = user.FirstName - LastName = user.LastName - PreferredName = user.PreferredName - Url = defaultArg user.Url "" - AccessLevel = string user.AccessLevel - CreatedOn = WebLog.localTime webLog user.CreatedOn - LastSeenOn = user.LastSeenOn |> Option.map (WebLog.localTime webLog) |> Option.toNullable - } + let fromUser (webLog: WebLog) (user: WebLogUser) = { + Id = string user.Id + Email = user.Email + FirstName = user.FirstName + LastName = user.LastName + PreferredName = user.PreferredName + Url = defaultArg user.Url "" + AccessLevel = string user.AccessLevel + CreatedOn = webLog.LocalTime user.CreatedOn + LastSeenOn = user.LastSeenOn |> Option.map webLog.LocalTime |> Option.toNullable + } /// View model for editing categories @@ -708,7 +700,7 @@ type EditPostModel = { } with /// Create an edit model from an existing past - static member fromPost webLog (post: Post) = + static member fromPost (webLog: WebLog) (post: Post) = let latest = match post.Revisions |> List.sortByDescending _.AsOf |> List.tryHead with | Some rev -> rev @@ -728,7 +720,7 @@ type EditPostModel = { MetaNames = post.Metadata |> List.map _.Name |> Array.ofList MetaValues = post.Metadata |> List.map _.Value |> Array.ofList SetPublished = false - PubOverride = post.PublishedOn |> Option.map (WebLog.localTime webLog) |> Option.toNullable + PubOverride = post.PublishedOn |> Option.map webLog.LocalTime |> Option.toNullable SetUpdated = false IsEpisode = Option.isSome post.Episode Media = episode.Media @@ -1111,22 +1103,20 @@ type PostListItem = { } with /// 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 = string post.Id - AuthorId = string post.AuthorId - Status = string post.Status - Title = post.Title - Permalink = string post.Permalink - PublishedOn = post.PublishedOn |> Option.map inTZ |> Option.toNullable - UpdatedOn = inTZ post.UpdatedOn - Text = addBaseToRelativeUrls extra post.Text - CategoryIds = post.CategoryIds |> List.map string - Tags = post.Tags - Episode = post.Episode - Metadata = post.Metadata - } + static member fromPost (webLog: WebLog) (post: Post) = { + Id = string post.Id + AuthorId = string post.AuthorId + Status = string post.Status + Title = post.Title + Permalink = string post.Permalink + PublishedOn = post.PublishedOn |> Option.map webLog.LocalTime |> Option.toNullable + UpdatedOn = webLog.LocalTime post.UpdatedOn + Text = addBaseToRelativeUrls webLog.ExtraPath post.Text + CategoryIds = post.CategoryIds |> List.map string + Tags = post.Tags + Episode = post.Episode + Metadata = post.Metadata + } /// View model for displaying posts diff --git a/src/MyWebLog/Caches.fs b/src/MyWebLog/Caches.fs index 74b09d9..9230cae 100644 --- a/src/MyWebLog/Caches.fs +++ b/src/MyWebLog/Caches.fs @@ -53,7 +53,7 @@ module Extensions = /// Does the current user have the requested level of access? member this.HasAccessLevel level = - defaultArg (this.UserAccessLevel |> Option.map (fun it -> it.HasAccess level)) false + defaultArg (this.UserAccessLevel |> Option.map _.HasAccess(level)) false open System.Collections.Concurrent @@ -93,13 +93,13 @@ module WebLogCache = _redirectCache[webLog.Id] <- webLog.RedirectRules |> List.map (fun it -> - let relUrl = Permalink >> WebLog.relativeUrl webLog + let relUrl = Permalink >> webLog.RelativeUrl let urlTo = if it.To.Contains "://" then it.To else relUrl it.To if it.IsRegex then - let pattern = if it.From.StartsWith "^" then $"^{relUrl (it.From.Substring 1)}" else it.From - RegEx (new Regex (pattern, RegexOptions.Compiled ||| RegexOptions.IgnoreCase), urlTo) + let pattern = if it.From.StartsWith "^" then $"^{relUrl it.From[1..]}" else it.From + RegEx(Regex(pattern, RegexOptions.Compiled ||| RegexOptions.IgnoreCase), urlTo) else - Text (relUrl it.From, urlTo)) + Text(relUrl it.From, urlTo)) /// Get all cached web logs let all () = diff --git a/src/MyWebLog/DotLiquidBespoke.fs b/src/MyWebLog/DotLiquidBespoke.fs index 2718d43..8cfdc9d 100644 --- a/src/MyWebLog/DotLiquidBespoke.fs +++ b/src/MyWebLog/DotLiquidBespoke.fs @@ -21,7 +21,7 @@ let assetExists fileName (webLog : WebLog) = ThemeAssetCache.get webLog.ThemeId |> List.exists (fun it -> it = fileName) /// Obtain the link from known types -let permalink (ctx : Context) (item : obj) (linkFunc : WebLog -> Permalink -> string) = +let permalink (item: obj) (linkFunc: Permalink -> string) = match item with | :? String as link -> Some link | :? DisplayPage as page -> Some page.Permalink @@ -29,64 +29,64 @@ let permalink (ctx : Context) (item : obj) (linkFunc : WebLog -> Permalink -> st | :? DropProxy as proxy -> Option.ofObj proxy["Permalink"] |> Option.map string | _ -> None |> function - | Some link -> linkFunc ctx.WebLog (Permalink link) + | Some link -> linkFunc (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 +type AbsoluteLinkFilter() = + static member AbsoluteLink(ctx: Context, item: obj) = + permalink item ctx.WebLog.AbsoluteUrl /// A filter to generate a link with posts categorized under the given category -type CategoryLinkFilter () = - static member CategoryLink (ctx : Context, catObj : obj) = +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 ctx.WebLog (Permalink $"category/{slug}/") + | Some slug -> ctx.WebLog.RelativeUrl(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) = +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 ctx.WebLog (Permalink $"admin/page/{pageId}/edit") + | Some pageId -> ctx.WebLog.RelativeUrl(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) = +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 ctx.WebLog (Permalink $"admin/post/{postId}/edit") + | Some postId -> ctx.WebLog.RelativeUrl(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 _, path = WebLog.hostAndPath ctx.WebLog - let path = if path = "" then path else $"{path.Substring 1}/" +type NavLinkFilter() = + static member NavLink(ctx: Context, url: string, text: string) = + let extraPath = ctx.WebLog.ExtraPath + let path = if extraPath = "" then "" else $"{extraPath[1..]}/" seq { "
  • " text "
  • " @@ -97,7 +97,7 @@ type NavLinkFilter () = /// A filter to generate a link for theme asset (image, stylesheet, script, etc.) type ThemeAssetFilter() = static member ThemeAsset(ctx: Context, asset: string) = - WebLog.relativeUrl ctx.WebLog (Permalink $"themes/{ctx.WebLog.ThemeId}/{asset}") + ctx.WebLog.RelativeUrl(Permalink $"themes/{ctx.WebLog.ThemeId}/{asset}") /// Create various items in the page header based on the state of the page being generated @@ -122,12 +122,12 @@ type PageHeadTag() = // RSS feeds and canonical URLs let feedLink title url = let escTitle = HttpUtility.HtmlAttributeEncode title - let relUrl = WebLog.relativeUrl webLog (Permalink url) + let relUrl = webLog.RelativeUrl(Permalink url) $"""{s}""" if webLog.Rss.IsFeedEnabled && getBool "is_home" then result.WriteLine(feedLink webLog.Name webLog.Rss.FeedName) - result.WriteLine $"""{s}""" + result.WriteLine $"""{s}""" if webLog.Rss.IsCategoryEnabled && getBool "is_category_home" then let slug = context.Environments[0].["slug"] :?> string @@ -139,12 +139,12 @@ type PageHeadTag() = if getBool "is_post" then let post = context.Environments[0].["model"] :?> PostDisplay - let url = WebLog.absoluteUrl webLog (Permalink post.Posts[0].Permalink) + let url = webLog.AbsoluteUrl (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) + let url = webLog.AbsoluteUrl (Permalink page.Permalink) result.WriteLine $"""{s}""" @@ -167,26 +167,26 @@ type PageFootTag () = /// A filter to generate a relative link type RelativeLinkFilter () = static member RelativeLink (ctx : Context, item : obj) = - permalink ctx item WebLog.relativeUrl + permalink item ctx.WebLog.RelativeUrl /// A filter to generate a link with posts tagged with the given tag -type TagLinkFilter () = - static member TagLink (ctx : Context, tag : string) = +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 ctx.WebLog (Permalink $"tag/{tagUrl}/") + | None -> tag.Replace(" ", "+") + |> function tagUrl -> ctx.WebLog.RelativeUrl(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 () +type UserLinksTag() = + inherit Tag() - override this.Render (context : Context, result : TextWriter) = - let link it = WebLog.relativeUrl context.WebLog (Permalink it) + override this.Render(context: Context, result: TextWriter) = + let link it = context.WebLog.RelativeUrl(Permalink it) seq { """
    RSS Settings diff --git a/src/admin-theme/user-edit.liquid b/src/admin-theme/user-edit.liquid deleted file mode 100644 index 84c4171..0000000 --- a/src/admin-theme/user-edit.liquid +++ /dev/null @@ -1,99 +0,0 @@ -
    -
    {{ page_title }}
    -
    - - -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    -
    -
    - {% unless model.is_new %}Change {% endunless %}Password - {% unless model.is_new %} -
    -

    Optional; leave blank not change the user’s password

    -
    - {% endunless %} -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    -
    -
    -
    -
    - - {% if model.is_new %} - - {% else %} - Cancel - {% endif %} -
    -
    -
    -
    diff --git a/src/admin-theme/user-list-body.liquid b/src/admin-theme/user-list-body.liquid deleted file mode 100644 index 8f7545c..0000000 --- a/src/admin-theme/user-list-body.liquid +++ /dev/null @@ -1,61 +0,0 @@ -
    -
    -
    -
    -
    - - {% include_template "_user-list-columns" %} - {%- assign badge = "ms-2 badge bg" -%} - {% for user in users -%} -
    -
    - {{ user.preferred_name }} - {%- if user.access_level == "Administrator" %} - ADMINISTRATOR - {%- elsif user.access_level == "WebLogAdmin" %} - WEB LOG ADMIN - {%- elsif user.access_level == "Editor" %} - EDITOR - {%- elsif user.access_level == "Author" %} - AUTHOR - {%- endif %}
    - {%- unless is_administrator == false and user.access_level == "Administrator" %} - - {%- assign user_url_base = "admin/settings/user/" | append: user.id -%} - - Edit - - {% unless user_id == user.id %} - - {%- assign user_del_link = user_url_base | append: "/delete" | relative_link -%} - - Delete - - {% endunless %} - - {%- endunless %} -
    - -
    - {{ user.created_on | date: "MMMM d, yyyy" }} -
    -
    - {% if user.last_seen_on %} - {{ user.last_seen_on | date: "MMMM d, yyyy" }} at - {{ user.last_seen_on | date: "h:mmtt" | downcase }} - {% else %} - -- - {% endif %} -
    -
    - {%- endfor %} -
    -
    -- 2.45.1 From 5b8a632e9d046ef6239cbc65feea817e6262ab28 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 10 Mar 2024 22:11:02 -0400 Subject: [PATCH 091/123] Complete main chapter functionality (#6) - Edit page still needs UI tweaks --- src/MyWebLog.Domain/SupportTypes.fs | 6 ++++- src/MyWebLog.Domain/ViewModels.fs | 13 +++++++-- src/MyWebLog/Handlers/Helpers.fs | 5 ++++ src/MyWebLog/Handlers/Post.fs | 36 ++++++++++++++++++++----- src/MyWebLog/Views/Post.fs | 42 ++++++++++++++++++++--------- 5 files changed, 80 insertions(+), 22 deletions(-) diff --git a/src/MyWebLog.Domain/SupportTypes.fs b/src/MyWebLog.Domain/SupportTypes.fs index acbb7d9..2ba8266 100644 --- a/src/MyWebLog.Domain/SupportTypes.fs +++ b/src/MyWebLog.Domain/SupportTypes.fs @@ -164,7 +164,7 @@ type Location = { Name: string /// A geographic coordinate string (RFC 5870) - Geo: string option + Geo: string /// An OpenStreetMap query Osm: string option @@ -182,6 +182,9 @@ type Chapter = { /// A URL for an image for this chapter ImageUrl: string option + /// A URL with information pertaining to this chapter + Url: string option + /// Whether this chapter is hidden IsHidden: bool option @@ -197,6 +200,7 @@ type Chapter = { { StartTime = Duration.Zero Title = None ImageUrl = None + Url = None IsHidden = None EndTime = None Location = None } diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index 5a2afcd..bb411aa 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -84,6 +84,9 @@ type DisplayChapter = { /// An image to display for this chapter ImageUrl: string + /// A URL with information about this chapter + Url: string + /// Whether this chapter should be displayed in podcast players IsHidden: bool @@ -106,10 +109,11 @@ type DisplayChapter = { { StartTime = pattern.Format chapter.StartTime Title = defaultArg chapter.Title "" ImageUrl = defaultArg chapter.ImageUrl "" + Url = defaultArg chapter.Url "" IsHidden = defaultArg chapter.IsHidden false EndTime = chapter.EndTime |> Option.map pattern.Format |> Option.defaultValue "" LocationName = chapter.Location |> Option.map _.Name |> Option.defaultValue "" - LocationGeo = chapter.Location |> Option.map _.Geo |> Option.flatten |> Option.defaultValue "" + LocationGeo = chapter.Location |> Option.map _.Geo |> Option.defaultValue "" LocationOsm = chapter.Location |> Option.map _.Osm |> Option.flatten |> Option.defaultValue "" } @@ -379,6 +383,9 @@ type EditChapterModel = { /// An image to display for this chapter ImageUrl: string + /// A URL with information about this chapter + Url: string + /// Whether this chapter should be displayed in podcast players IsHidden: bool @@ -406,6 +413,7 @@ type EditChapterModel = { StartTime = it.StartTime Title = it.Title ImageUrl = it.ImageUrl + Url = it.Url IsHidden = it.IsHidden EndTime = it.EndTime LocationName = it.LocationName @@ -429,10 +437,11 @@ type EditChapterModel = { let location = match noneIfBlank this.LocationName with | None -> None - | Some name -> Some { Name = name; Geo = noneIfBlank this.LocationGeo; Osm = noneIfBlank this.LocationOsm } + | Some name -> Some { Name = name; Geo = this.LocationGeo; Osm = noneIfBlank this.LocationOsm } { StartTime = parseDuration (nameof this.StartTime) this.StartTime Title = noneIfBlank this.Title ImageUrl = noneIfBlank this.ImageUrl + Url = noneIfBlank this.Url IsHidden = if this.IsHidden then Some true else None EndTime = noneIfBlank this.EndTime |> Option.map (parseDuration (nameof this.EndTime)) Location = location } diff --git a/src/MyWebLog/Handlers/Helpers.fs b/src/MyWebLog/Handlers/Helpers.fs index 7f01db7..00f7274 100644 --- a/src/MyWebLog/Handlers/Helpers.fs +++ b/src/MyWebLog/Handlers/Helpers.fs @@ -421,6 +421,11 @@ open System.Threading.Tasks /// Create a Task with a Some result for the given object let someTask<'T> (it: 'T) = Task.FromResult(Some it) +/// Create an absolute URL from a string that may already be an absolute URL +let absoluteUrl (url: string) (ctx: HttpContext) = + if url.StartsWith "http" then url else ctx.WebLog.AbsoluteUrl (Permalink url) + + open System.Collections.Generic open MyWebLog.Data diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index 9f0a508..279ca22 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -208,18 +208,42 @@ let home : HttpHandler = fun next ctx -> task { } // GET /{post-permalink}?chapters -let chapters (post: Post) : HttpHandler = +let chapters (post: Post) : HttpHandler = fun next ctx -> match post.Episode with | Some ep -> match ep.Chapters with | Some chapters -> - - json chapters + let chapterData = + chapters + |> Seq.ofList + |> Seq.map (fun it -> + let dic = Dictionary() + dic["startTime"] <- Math.Round(it.StartTime.TotalSeconds, 2) + it.Title |> Option.iter (fun ttl -> dic["title"] <- ttl) + it.ImageUrl |> Option.iter (fun img -> dic["img"] <- absoluteUrl img ctx) + it.Url |> Option.iter (fun url -> dic["url"] <- absoluteUrl url ctx) + it.IsHidden |> Option.iter (fun toc -> dic["toc"] <- not toc) + it.EndTime |> Option.iter (fun ent -> dic["endTime"] <- Math.Round(ent.TotalSeconds, 2)) + it.Location |> Option.iter (fun loc -> + let locData = Dictionary() + locData["name"] <- loc.Name + locData["geo"] <- loc.Geo + loc.Osm |> Option.iter (fun osm -> locData["osm"] <- osm) + dic["location"] <- locData) + dic) + |> ResizeArray + let jsonFile = Dictionary() + jsonFile["version"] <- "1.2.0" + jsonFile["title"] <- post.Title + jsonFile["fileName"] <- absoluteUrl ep.Media ctx + if defaultArg ep.ChapterWaypoints false then jsonFile["waypoints"] <- true + jsonFile["chapters"] <- chapterData + json jsonFile next ctx | None -> match ep.ChapterFile with - | Some file -> redirectTo true file - | None -> Error.notFound - | None -> Error.notFound + | Some file -> redirectTo true file next ctx + | None -> Error.notFound next ctx + | None -> Error.notFound next ctx // ~~ ADMINISTRATION ~~ diff --git a/src/MyWebLog/Views/Post.fs b/src/MyWebLog/Views/Post.fs index f64eb48..9878310 100644 --- a/src/MyWebLog/Views/Post.fs +++ b/src/MyWebLog/Views/Post.fs @@ -46,7 +46,7 @@ let chapterEdit (model: EditChapterModel) app = [ span [ _class "form-text" ] [ raw "Optional" ] ] ] - div [ _class "col-12 col-lg-6 offset-xl-1 mb-3" ] [ + div [ _class "col-12 col-lg-6 col-xl-5 mb-3" ] [ div [ _class "form-floating" ] [ input [ _type "text"; _id "image_url"; _name "ImageUrl"; _class "form-control" _value model.ImageUrl; _placeholder "Image URL" ] @@ -56,7 +56,17 @@ let chapterEdit (model: EditChapterModel) app = [ ] ] ] - div [ _class "col-12 col-lg-6 col-xl-4 mb-3 align-self-end d-flex flex-column" ] [ + div [ _class "col-12 col-lg-6 col-xl-5 mb-3" ] [ + div [ _class "form-floating" ] [ + input [ _type "text"; _id "url"; _name "Url"; _class "form-control"; _value model.Url + _placeholder "URL" ] + label [ _for "url" ] [ raw "URL" ] + span [ _class "form-text" ] [ + raw "Optional; informational link for this chapter" + ] + ] + ] + div [ _class "col-12 col-lg-6 offset-lg-3 col-xl-2 offset-xl-0 mb-3 align-self-end d-flex flex-column" ] [ div [ _class "form-check form-switch mb-3" ] [ input [ _type "checkbox"; _id "is_hidden"; _name "IsHidden"; _class "form-check-input" _value "true" @@ -87,11 +97,10 @@ let chapterEdit (model: EditChapterModel) app = [ div [ _class "col-6 col-lg-4 offset-lg-2 mb-3" ] [ div [ _class "form-floating" ] [ input [ _type "text"; _id "location_geo"; _name "LocationGeo"; _class "form-control" - _value model.LocationGeo; _placeholder "Location Geo URL" + _value model.LocationGeo; _placeholder "Location Geo URL"; _required if not hasLoc then _disabled ] label [ _for "location_geo" ] [ raw "Geo URL" ] em [ _class "form-text" ] [ - raw "Optional; " a [ _href "https://github.com/Podcastindex-org/podcast-namespace/blob/main/location/location.md#geo-recommended" _target "_blank"; _rel "noopener" ] [ raw "see spec" @@ -142,16 +151,19 @@ let chapterList withNew (model: ManageChaptersModel) app = antiCsrf app input [ _type "hidden"; _name "Id"; _value model.Id ] div [ _class "row mwl-table-heading" ] [ - div [ _class "col" ] [ raw "Start" ] - div [ _class "col" ] [ raw "Title" ] - div [ _class "col" ] [ raw "Image?" ] - div [ _class "col" ] [ raw "Location?" ] + div [ _class "col-3 col-md-2" ] [ raw "Start" ] + div [ _class "col-3 col-md-6 col-lg-8" ] [ raw "Title" ] + div [ _class "col-3 col-md-2 col-lg-1 text-center" ] [ raw "Image?" ] + div [ _class "col-3 col-md-2 col-lg-1 text-center" ] [ raw "Location?" ] ] yield! model.Chapters |> List.mapi (fun idx chapter -> div [ _class "row mwl-table-detail"; _id $"chapter{idx}" ] [ - div [ _class "col" ] [ txt (startTimePattern.Format chapter.StartTime) ] - div [ _class "col" ] [ - txt (defaultArg chapter.Title ""); br [] + div [ _class "col-3 col-md-2" ] [ txt (startTimePattern.Format chapter.StartTime) ] + div [ _class "col-3 col-md-6 col-lg-8" ] [ + match chapter.Title with + | Some title -> txt title + | None -> em [ _class "text-muted" ] [ raw "no title" ] + br [] small [] [ if withNew then raw " " @@ -167,8 +179,12 @@ let chapterList withNew (model: ManageChaptersModel) app = ] ] ] - div [ _class "col" ] [ raw (if Option.isSome chapter.ImageUrl then "Y" else "N") ] - div [ _class "col" ] [ raw (if Option.isSome chapter.Location then "Y" else "N") ] + div [ _class "col-3 col-md-2 col-lg-1 text-center" ] [ + raw (match chapter.ImageUrl with Some _ -> "Y" | None -> "N") + ] + div [ _class "col-3 col-md-2 col-lg-1 text-center" ] [ + raw (match chapter.Location with Some _ -> "Y" | None -> "N") + ] ]) div [ _class "row pb-3"; _id "chapter-1" ] [ let newLink = relUrl app $"admin/post/{model.Id}/chapter/-1" -- 2.45.1 From 90e6f78248e59a7bf905c012116cb092a4f30375 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 10 Mar 2024 23:14:05 -0400 Subject: [PATCH 092/123] Migrate post list template --- src/MyWebLog/Handlers/Post.fs | 10 ++- src/MyWebLog/Handlers/Routes.fs | 2 +- src/MyWebLog/Views/Post.fs | 107 +++++++++++++++++++++++++++++++ src/admin-theme/post-list.liquid | 98 ---------------------------- 4 files changed, 112 insertions(+), 105 deletions(-) delete mode 100644 src/admin-theme/post-list.liquid diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index 279ca22..0555999 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -254,10 +254,7 @@ let all pageNbr : HttpHandler = requireAccess Author >=> fun next ctx -> task { let data = ctx.Data let! posts = data.Post.FindPageOfPosts ctx.WebLog.Id pageNbr 25 let! hash = preparePostList ctx.WebLog posts AdminList "" pageNbr 25 data - return! - addToHash ViewContext.PageTitle "Posts" hash - |> withAntiCsrf ctx - |> adminView "post-list" next ctx + return! adminPage "Posts" true (Views.Post.list (hash[ViewContext.Model] :?> PostDisplay)) next ctx } // GET /admin/post/{id}/edit @@ -294,12 +291,13 @@ let edit postId : HttpHandler = requireAccess Author >=> fun next ctx -> task { | None -> return! Error.notFound next ctx } -// POST /admin/post/{id}/delete +// DELETE /admin/post/{id} let delete postId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { match! ctx.Data.Post.Delete (PostId postId) ctx.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 "admin/posts" next ctx + //return! redirectToGet "admin/posts" next ctx + return! all 1 next ctx } // GET /admin/post/{id}/permalinks diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index 7f6429c..835a171 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -184,7 +184,6 @@ let router : HttpHandler = choose [ route "/save" >=> Post.save route "/permalinks" >=> Post.savePermalinks routef "/%s/chapter/%i" Post.saveChapter - routef "/%s/delete" Post.delete routef "/%s/revision/%s/delete" Post.deleteRevision routef "/%s/revision/%s/restore" Post.restoreRevision routef "/%s/revisions/purge" Post.purgeRevisions @@ -220,6 +219,7 @@ let router : HttpHandler = choose [ ] DELETE >=> validateCsrf >=> choose [ subRoute "/post" (choose [ + routef "/%s" Post.delete routef "/%s/chapter/%i" Post.deleteChapter ]) subRoute "/settings" (requireAccess WebLogAdmin >=> choose [ diff --git a/src/MyWebLog/Views/Post.fs b/src/MyWebLog/Views/Post.fs index 9878310..2da63c4 100644 --- a/src/MyWebLog/Views/Post.fs +++ b/src/MyWebLog/Views/Post.fs @@ -218,3 +218,110 @@ let chapters withNew (model: ManageChaptersModel) app = [ yield! chapterList withNew model app ] ] + +/// Display a list of posts +let list (model: PostDisplay) app = [ + let dateCol = "col-xs-12 col-md-3 col-lg-2" + let titleCol = "col-xs-12 col-md-7 col-lg-6 col-xl-5 col-xxl-4" + let authorCol = "col-xs-12 col-md-2 col-lg-1" + let tagCol = "col-lg-3 col-xl-4 col-xxl-5 d-none d-lg-inline-block" + h2 [ _class "my-3" ] [ txt app.PageTitle ] + article [] [ + a [ _href (relUrl app "admin/post/new/edit"); _class "btn btn-primary btn-sm mb-3" ] [ raw "Write a New Post" ] + if model.Posts.Length > 0 then + form [ _method "post"; _class "container mb-3"; _hxTarget "body" ] [ + antiCsrf app + div [ _class "row mwl-table-heading" ] [ + div [ _class dateCol ] [ + span [ _class "d-md-none" ] [ raw "Post" ]; span [ _class "d-none d-md-inline" ] [ raw "Date" ] + ] + div [ _class $"{titleCol} d-none d-md-inline-block" ] [ raw "Title" ] + div [ _class $"{authorCol} d-none d-md-inline-block" ] [ raw "Author" ] + div [ _class tagCol ] [ raw "Tags" ] + ] + for post in model.Posts do + div [ _class "row mwl-table-detail" ] [ + div [ _class $"{dateCol} no-wrap" ] [ + small [ _class "d-md-none" ] [ + if post.PublishedOn.HasValue then + raw "Published "; txt (post.PublishedOn.Value.ToString "MMMM d, yyyy") + else raw "Not Published" + if post.PublishedOn.HasValue && post.PublishedOn.Value <> post.UpdatedOn then + em [ _class "text-muted" ] [ + raw " (Updated "; txt (post.UpdatedOn.ToString "MMMM d, yyyy"); raw ")" + ] + ] + span [ _class "d-none d-md-inline" ] [ + if post.PublishedOn.HasValue then txt (post.PublishedOn.Value.ToString "MMMM d, yyyy") + else raw "Not Published" + if not post.PublishedOn.HasValue || post.PublishedOn.Value <> post.UpdatedOn then + br [] + small [ _class "text-muted" ] [ + em [] [ txt (post.UpdatedOn.ToString "MMMM d, yyyy") ] + ] + ] + ] + div [ _class titleCol ] [ + if Option.isSome post.Episode then + span [ _class "badge bg-success float-end text-uppercase mt-1" ] [ raw "Episode" ] + raw post.Title; br [] + small [] [ + let postUrl = relUrl app $"admin/post/{post.Id}" + a [ _href (relUrl app post.Permalink); _target "_blank" ] [ raw "View Post" ] + if app.IsEditor || (app.IsAuthor && app.UserId.Value = WebLogUserId post.AuthorId) then + span [ _class "text-muted" ] [ raw " • " ] + a [ _href $"{postUrl}/edit" ] [ raw "Edit" ] + if app.IsWebLogAdmin then + span [ _class "text-muted" ] [ raw " • " ] + a [ _href postUrl; _hxDelete postUrl; _class "text-danger" + _hxConfirm $"Are you sure you want to delete the post “{post.Title}”? This action cannot be undone." ] [ + raw "Delete" + ] + ] + ] + div [ _class authorCol ] [ + let author = + model.Authors + |> List.tryFind (fun a -> a.Name = post.AuthorId) + |> Option.map _.Value + |> Option.defaultValue "--" + |> txt + small [ _class "d-md-none" ] [ + raw "Authored by "; author; raw " | " + raw (if post.Tags.Length = 0 then "No" else string post.Tags.Length) + raw " Tag"; if post.Tags.Length <> 0 then raw "s" + ] + span [ _class "d-none d-md-inline" ] [ author ] + ] + div [ _class tagCol ] [ + let tags = + post.Tags |> List.mapi (fun idx tag -> idx, span [ _class "no-wrap" ] [ txt tag ]) + for tag in tags do + snd tag + if fst tag < tags.Length - 1 then raw ", " + ] + ] + ] + if Option.isSome model.NewerLink || Option.isSome model.OlderLink then + div [ _class "d-flex justify-content-evenly mb-3" ] [ + div [] [ + if Option.isSome model.NewerLink then + p [] [ + a [ _href model.NewerLink.Value; _class "btn btn-secondary"; ] [ + raw "« Newer Posts" + ] + ] + ] + div [ _class "text-right" ] [ + if Option.isSome model.OlderLink then + p [] [ + a [ _href model.OlderLink.Value; _class "btn btn-secondary" ] [ + raw "Older Posts »" + ] + ] + ] + ] + else + p [ _class "text-muted fst-italic text-center" ] [ raw "This web log has no posts" ] + ] +] diff --git a/src/admin-theme/post-list.liquid b/src/admin-theme/post-list.liquid deleted file mode 100644 index 010ef56..0000000 --- a/src/admin-theme/post-list.liquid +++ /dev/null @@ -1,98 +0,0 @@ -

    {{ page_title }}

    -
    - Write a New Post - {%- assign post_count = model.posts | size -%} - {%- if post_count > 0 %} -
    - - {%- 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
    -
    - {% 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 %} -
    -
    -
    - {%- if post.episode %}Episode{% endif -%} - {{ post.title }}
    - - View Post - {% if is_editor or is_author and user_id == post.author_id %} - - Edit - {% endif %} - {% if is_web_log_admin %} - - {%- assign post_del_link = "admin/post/" | append: post.id | append: "/delete" | relative_link -%} - - Delete - - {% endif %} - -
    -
    - {%- 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 %} -
    - {% if model.newer_link or model.older_link %} -
    -
    - {% if model.newer_link %} -

    « Newer Posts - {% endif %} -

    -
    - {% if model.older_link %} -

    Older Posts » - {% endif %} -

    -
    - {% endif %} - {% else %} -

    This web log has no posts - {% endif %} -

    -- 2.45.1 From 5f114c79556d69194e3b40cf67e98226b50b18b8 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Tue, 12 Mar 2024 18:58:15 -0400 Subject: [PATCH 093/123] Move redirect/tag map templates to GVE - Fix chapter tests - Apply generator string change for next release --- src/MyWebLog.Tests/Domain/ViewModelsTests.fs | 10 +- src/MyWebLog/Handlers/Admin.fs | 159 ++++++-------- src/MyWebLog/Handlers/Routes.fs | 18 +- src/MyWebLog/Views/Admin.fs | 218 +++++++++++++++++++ src/MyWebLog/Views/Helpers.fs | 4 + src/MyWebLog/Views/Post.fs | 13 +- src/MyWebLog/Views/User.fs | 8 +- src/MyWebLog/appsettings.json | 1 - src/admin-theme/redirect-edit.liquid | 48 ---- src/admin-theme/redirect-list.liquid | 75 ------- src/admin-theme/settings.liquid | 2 +- src/admin-theme/tag-mapping-edit.liquid | 27 --- src/admin-theme/tag-mapping-list-body.liquid | 43 ---- 13 files changed, 309 insertions(+), 317 deletions(-) delete mode 100644 src/admin-theme/redirect-edit.liquid delete mode 100644 src/admin-theme/redirect-list.liquid delete mode 100644 src/admin-theme/tag-mapping-edit.liquid delete mode 100644 src/admin-theme/tag-mapping-list-body.liquid diff --git a/src/MyWebLog.Tests/Domain/ViewModelsTests.fs b/src/MyWebLog.Tests/Domain/ViewModelsTests.fs index 0915383..e00af60 100644 --- a/src/MyWebLog.Tests/Domain/ViewModelsTests.fs +++ b/src/MyWebLog.Tests/Domain/ViewModelsTests.fs @@ -52,12 +52,14 @@ let displayChapterTests = testList "DisplayChapter.FromChapter" [ { StartTime = Duration.FromSeconds 7201.43242 Title = Some "My Test Chapter" ImageUrl = Some "two-hours-in.jpg" + Url = Some "https://example.com/about" IsHidden = Some true EndTime = Some (Duration.FromSeconds 7313.788) - Location = Some { Name = "Over Here"; Geo = Some "geo:23432"; Osm = Some "SF98fFSu-8" } } + Location = Some { Name = "Over Here"; Geo = "geo:23432"; Osm = Some "SF98fFSu-8" } } Expect.equal chapter.StartTime "2:00:01.43" "Start time not filled/formatted properly" Expect.equal chapter.Title "My Test Chapter" "Title not filled properly" Expect.equal chapter.ImageUrl "two-hours-in.jpg" "Image URL not filled properly" + Expect.equal chapter.Url "https://example.com/about" "URL not filled properly" Expect.isTrue chapter.IsHidden "Is hidden flag not filled properly" Expect.equal chapter.EndTime "2:01:53.78" "End time not filled/formatted properly" Expect.equal chapter.LocationName "Over Here" "Location name not filled properly" @@ -1121,9 +1123,9 @@ let manageChaptersModelTests = testList "ManageChaptersModel.Create" [ Expect.equal model.Id "test-post" "ID not filled properly" Expect.equal model.Title "Look at all these chapters" "Title not filled properly" Expect.hasLength model.Chapters 3 "There should be three chapters" - Expect.equal model.Chapters[0].StartTime "0:00:18" "First chapter not filled properly" - Expect.equal model.Chapters[1].StartTime "0:00:36" "Second chapter not filled properly" - Expect.equal model.Chapters[2].StartTime "0:03:00.7" "Third chapter not filled properly" + Expect.equal model.Chapters[0].StartTime (Duration.FromSeconds 18L) "First chapter not filled properly" + Expect.equal model.Chapters[1].StartTime (Duration.FromSeconds 36L) "Second chapter not filled properly" + Expect.equal model.Chapters[2].StartTime (Duration.FromSeconds 180.7) "Third chapter not filled properly" } ] diff --git a/src/MyWebLog/Handlers/Admin.fs b/src/MyWebLog/Handlers/Admin.fs index e0f61ef..e04a9b1 100644 --- a/src/MyWebLog/Handlers/Admin.fs +++ b/src/MyWebLog/Handlers/Admin.fs @@ -214,34 +214,24 @@ module RedirectRules = open Microsoft.AspNetCore.Http // GET /admin/settings/redirect-rules - let all : HttpHandler = fun next ctx -> task { - return! - hashForPage "Redirect Rules" - |> withAntiCsrf ctx - |> addToHash "redirections" ctx.WebLog.RedirectRules - |> adminView "redirect-list" next ctx - } + let all : HttpHandler = fun next ctx -> + adminPage "Redirect Rules" true (Views.Admin.redirectList ctx.WebLog.RedirectRules) next ctx // GET /admin/settings/redirect-rules/[index] - let edit idx : HttpHandler = fun next ctx -> task { - if idx = -1 then - return! - hashForPage "Add Redirect Rule" - |> addToHash "model" (EditRedirectRuleModel.FromRule -1 RedirectRule.Empty) - |> withAntiCsrf ctx - |> adminBareView "redirect-edit" next ctx - else - let rules = ctx.WebLog.RedirectRules - if rules.Length < idx || idx < 0 then - return! Error.notFound next ctx - else - return! - hashForPage "Edit Redirect Rule" - |> addToHash "model" (EditRedirectRuleModel.FromRule idx (List.item idx rules)) - |> withAntiCsrf ctx - |> adminBareView "redirect-edit" next ctx - } - + let edit idx : HttpHandler = fun next ctx -> + let titleAndModel = + if idx = -1 then + Some ("Add", Views.Admin.redirectEdit (EditRedirectRuleModel.FromRule -1 RedirectRule.Empty)) + else + let rules = ctx.WebLog.RedirectRules + if rules.Length < idx || idx < 0 then + None + else + Some ("Edit", (Views.Admin.redirectEdit (EditRedirectRuleModel.FromRule idx (List.item idx rules)))) + match titleAndModel with + | Some (title, model) -> adminBarePage $"{title} Redirect Rule" true model next ctx + | None -> Error.notFound next ctx + /// Update the web log's redirect rules in the database, the request web log, and the web log cache let private updateRedirectRules (ctx: HttpContext) webLog = backgroundTask { do! ctx.Data.WebLog.UpdateRedirectRules webLog @@ -251,16 +241,15 @@ module RedirectRules = // POST /admin/settings/redirect-rules/[index] let save idx : HttpHandler = fun next ctx -> task { - let! model = ctx.BindFormAsync() - let isNew = idx = -1 - let rules = ctx.WebLog.RedirectRules - let rule = model.ToRule() - let newRules = - match isNew with - | true when model.InsertAtTop -> List.insertAt 0 rule rules - | true -> List.insertAt rules.Length rule rules - | false -> rules |> List.removeAt idx |> List.insertAt idx rule - do! updateRedirectRules ctx { ctx.WebLog with RedirectRules = newRules } + let! model = ctx.BindFormAsync() + let rule = model.ToRule() + let rules = + ctx.WebLog.RedirectRules + |> match idx with + | -1 when model.InsertAtTop -> List.insertAt 0 rule + | -1 -> List.insertAt ctx.WebLog.RedirectRules.Length rule + | _ -> List.removeAt idx >> List.insertAt idx rule + do! updateRedirectRules ctx { ctx.WebLog with RedirectRules = rules } do! addMessage ctx { UserMessage.Success with Message = "Redirect rule saved successfully" } return! all next ctx } @@ -287,7 +276,7 @@ module RedirectRules = return! all next ctx } - // POST /admin/settings/redirect-rules/[index]/delete + // DELETE /admin/settings/redirect-rules/[index] let delete idx : HttpHandler = fun next ctx -> task { if idx < 0 || idx >= ctx.WebLog.RedirectRules.Length then return! Error.notFound next ctx @@ -302,25 +291,10 @@ module RedirectRules = /// ~~~ TAG MAPPINGS ~~~ module TagMapping = - open Microsoft.AspNetCore.Http - - /// Add tag mappings to the given hash - let withTagMappings (ctx: HttpContext) hash = task { - let! mappings = ctx.Data.TagMap.FindByWebLog ctx.WebLog.Id - return - addToHash "mappings" mappings hash - |> addToHash "mapping_ids" ( - mappings - |> List.map (fun it -> { Name = it.Tag; Value = string it.Id })) - } - // GET /admin/settings/tag-mappings let all : HttpHandler = fun next ctx -> task { - let! hash = - hashForPage "" - |> withAntiCsrf ctx - |> withTagMappings ctx - return! adminBareView "tag-mapping-list-body" next ctx hash + let! mappings = ctx.Data.TagMap.FindByWebLog ctx.WebLog.Id + return! adminBarePage "Tag Mapping List" true (Views.Admin.tagMapList mappings) next ctx } // GET /admin/settings/tag-mapping/{id}/edit @@ -332,10 +306,9 @@ module TagMapping = match! tagMap with | Some tm -> return! - hashForPage (if isNew then "Add Tag Mapping" else $"Mapping for {tm.Tag} Tag") - |> withAntiCsrf ctx - |> addToHash ViewContext.Model (EditTagMapModel.FromMapping tm) - |> adminBareView "tag-mapping-edit" next ctx + adminBarePage + (if isNew then "Add Tag Mapping" else $"Mapping for {tm.Tag} Tag") true + (Views.Admin.tagMapEdit (EditTagMapModel.FromMapping tm)) next ctx | None -> return! Error.notFound next ctx } @@ -354,7 +327,7 @@ module TagMapping = | None -> return! Error.notFound next ctx } - // POST /admin/settings/tag-mapping/{id}/delete + // DELETE /admin/settings/tag-mapping/{id} let delete 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" } @@ -531,44 +504,36 @@ module WebLog = // GET /admin/settings let settings : HttpHandler = fun next ctx -> task { let data = ctx.Data - match! TemplateCache.get adminTheme "tag-mapping-list-body" ctx.Data with - | Ok tagMapTemplate -> - let! allPages = data.Page.All ctx.WebLog.Id - let! themes = data.Theme.All() - let! hash = - hashForPage "Web Log Settings" - |> withAntiCsrf ctx - |> addToHash ViewContext.Model (SettingsModel.FromWebLog ctx.WebLog) - |> addToHash "pages" ( - seq { - KeyValuePair.Create("posts", "- First Page of Posts -") - yield! allPages - |> List.sortBy _.Title.ToLower() - |> List.map (fun p -> KeyValuePair.Create(string p.Id, p.Title)) - } - |> Array.ofSeq) - |> addToHash "themes" ( - themes - |> Seq.ofList - |> Seq.map (fun it -> - KeyValuePair.Create(string it.Id, $"{it.Name} (v{it.Version})")) - |> Array.ofSeq) - |> addToHash "upload_values" [| - KeyValuePair.Create(string Database, "Database") - KeyValuePair.Create(string Disk, "Disk") - |] - |> addToHash "rss_model" (EditRssModel.FromRssOptions ctx.WebLog.Rss) - |> addToHash "custom_feeds" ( - ctx.WebLog.Rss.CustomFeeds - |> List.map (DisplayCustomFeed.FromFeed (CategoryCache.get ctx)) - |> Array.ofList) - |> addViewContext ctx - let! hash' = TagMapping.withTagMappings ctx hash - return! - hash' - |> addToHash "tag_mapping_list" (tagMapTemplate.Render hash') - |> adminView "settings" next ctx - | Error message -> return! Error.server message next ctx + let! allPages = data.Page.All ctx.WebLog.Id + let! themes = data.Theme.All() + return! + hashForPage "Web Log Settings" + |> withAntiCsrf ctx + |> addToHash ViewContext.Model (SettingsModel.FromWebLog ctx.WebLog) + |> addToHash "pages" ( + seq { + KeyValuePair.Create("posts", "- First Page of Posts -") + yield! allPages + |> List.sortBy _.Title.ToLower() + |> List.map (fun p -> KeyValuePair.Create(string p.Id, p.Title)) + } + |> Array.ofSeq) + |> addToHash "themes" ( + themes + |> Seq.ofList + |> Seq.map (fun it -> + KeyValuePair.Create(string it.Id, $"{it.Name} (v{it.Version})")) + |> Array.ofSeq) + |> addToHash "upload_values" [| + KeyValuePair.Create(string Database, "Database") + KeyValuePair.Create(string Disk, "Disk") + |] + |> addToHash "rss_model" (EditRssModel.FromRssOptions ctx.WebLog.Rss) + |> addToHash "custom_feeds" ( + ctx.WebLog.Rss.CustomFeeds + |> List.map (DisplayCustomFeed.FromFeed (CategoryCache.get ctx)) + |> Array.ofList) + |> adminView "settings" next ctx } // POST /admin/settings diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index 835a171..a4ef6f9 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -196,16 +196,12 @@ let router : HttpHandler = choose [ routef "/%s/delete" Feed.deleteCustomFeed ]) subRoute "/redirect-rules" (choose [ - routef "/%i" Admin.RedirectRules.save - routef "/%i/up" Admin.RedirectRules.moveUp - routef "/%i/down" Admin.RedirectRules.moveDown - routef "/%i/delete" Admin.RedirectRules.delete + routef "/%i" Admin.RedirectRules.save + routef "/%i/up" Admin.RedirectRules.moveUp + routef "/%i/down" Admin.RedirectRules.moveDown ]) - subRoute "/tag-mapping" (choose [ - route "/save" >=> Admin.TagMapping.save - routef "/%s/delete" Admin.TagMapping.delete - ]) - route "/user/save" >=> User.save + route "/tag-mapping/save" >=> Admin.TagMapping.save + route "/user/save" >=> User.save ]) subRoute "/theme" (choose [ route "/new" >=> Admin.Theme.save @@ -223,7 +219,9 @@ let router : HttpHandler = choose [ routef "/%s/chapter/%i" Post.deleteChapter ]) subRoute "/settings" (requireAccess WebLogAdmin >=> choose [ - routef "/user/%s" User.delete + routef "/user/%s" User.delete + routef "/redirect-rules/%i" Admin.RedirectRules.delete + routef "/tag-mapping/%s" Admin.TagMapping.delete ]) ] ]) diff --git a/src/MyWebLog/Views/Admin.fs b/src/MyWebLog/Views/Admin.fs index 335d4ce..7e4aac1 100644 --- a/src/MyWebLog/Views/Admin.fs +++ b/src/MyWebLog/Views/Admin.fs @@ -1,6 +1,10 @@ module MyWebLog.Views.Admin +open Giraffe.Htmx.Common open Giraffe.ViewEngine +open Giraffe.ViewEngine.Accessibility +open Giraffe.ViewEngine.Htmx +open MyWebLog open MyWebLog.ViewModels /// The main dashboard @@ -75,3 +79,217 @@ let dashboard (model: DashboardModel) app = [ ] ] ] + + +/// Redirect Rule edit form +let redirectEdit (model: EditRedirectRuleModel) app = [ + let url = relUrl app $"admin/settings/redirect-rules/{model.RuleId}" + h3 [] [ raw (if model.RuleId < 0 then "Add" else "Edit"); raw " Redirect Rule" ] + form [ _action url; _hxPost url; _hxTarget "body"; _method "post"; _class "container" ] [ + antiCsrf app + input [ _type "hidden"; _name "RuleId"; _value (string model.RuleId) ] + div [ _class "row" ] [ + div [ _class "col-12 col-lg-5 mb-3" ] [ + div [ _class "form-floating" ] [ + input [ _type "text"; _name "From"; _id "from"; _class "form-control" + _placeholder "From local URL/pattern"; _autofocus; _required; _value model.From ] + label [ _for "from" ] [ raw "From" ] + ] + ] + div [ _class "col-12 col-lg-5 mb-3" ] [ + div [ _class "form-floating" ] [ + input [ _type "text"; _name "To"; _id "to"; _class "form-control"; _placeholder "To URL/pattern" + _required; _value model.To ] + label [ _for "to" ] [ raw "To" ] + ] + ] + div [ _class "col-12 col-lg-2 mb-3" ] [ + div [ _class "form-check form-switch" ] [ + input [ _type "checkbox"; _name "IsRegex"; _id "isRegex"; _class "form-check-input"; _value "true" + if model.IsRegex then _checked ] + label [ _for "isRegex" ] [ raw "Use RegEx" ] + ] + ] + ] + if model.RuleId < 0 then + div [ _class "row mb-3" ] [ + div [ _class "col-12 text-center" ] [ + label [ _class "me-1" ] [ raw "Add Rule" ] + div [ _class "btn-group btn-group-sm"; _roleGroup; _ariaLabel "New rule placement button group" ] [ + input [ _type "radio"; _name "InsertAtTop"; _id "at_top"; _class "btn-check"; _value "true" ] + label [ _class "btn btn-sm btn-outline-secondary"; _for "at_top" ] [ raw "Top" ] + input [ _type "radio"; _name "InsertAtTop"; _id "at_bot"; _class "btn-check"; _value "false" + _checked ] + label [ _class "btn btn-sm btn-outline-secondary"; _for "at_bot" ] [ raw "Bottom" ] + ] + ] + ] + div [ _class "row mb-3" ] [ + div [ _class "col text-center" ] [ + button [ _type "submit"; _class "btn btn-sm btn-primary" ] [ raw "Save Changes" ] + a [ _href (relUrl app "admin/settings/redirect-rules"); _class "btn btn-sm btn-secondary ms-3" ] [ + raw "Cancel" + ] + ] + ] + ] +] + + +/// The list of current redirect rules +let redirectList (model: RedirectRule list) app = [ + // Generate the detail for a redirect rule + let ruleDetail idx (rule: RedirectRule) = + let ruleId = $"rule_{idx}" + div [ _class "row mwl-table-detail"; _id ruleId ] [ + div [ _class "col-5 no-wrap" ] [ + txt rule.From; br [] + small [] [ + let ruleUrl = relUrl app $"admin/settings/redirect-rules/{idx}" + a [ _href ruleUrl; _hxTarget $"#{ruleId}"; _hxSwap $"{HxSwap.InnerHtml} show:#{ruleId}:top" ] [ + raw "Edit" + ] + if idx > 0 then + span [ _class "text-muted" ] [ raw " • " ] + a [ _href $"{ruleUrl}/up"; _hxPost $"{ruleUrl}/up" ] [ raw "Move Up" ] + if idx <> model.Length - 1 then + span [ _class "text-muted" ] [ raw " • " ] + a [ _href $"{ruleUrl}/down"; _hxPost $"{ruleUrl}/down" ] [ raw "Move Down" ] + span [ _class "text-muted" ] [ raw " • " ] + a [ _class "text-danger"; _href ruleUrl; _hxDelete ruleUrl + _hxConfirm "Are you sure you want to delete this redirect rule?" ] [ + raw "Delete" + ] + ] + ] + div [ _class "col-5" ] [ txt rule.To ] + div [ _class "col-2 text-center" ] [ yesOrNo rule.IsRegex ] + ] + h2 [ _class "my-3" ] [ raw app.PageTitle ] + article [] [ + p [ _class "mb-3" ] [ + a [ _href (relUrl app "admin/settings") ] [ raw "« Back to Settings" ] + ] + div [ _class "container" ] [ + div [ _class "row" ] [ + div [ _class "col" ] [ + a [ _href (relUrl app "admin/settings/redirect-rules/-1"); _class "btn btn-primary btn-sm mb-3" + _hxTarget "#rule_new" ] [ + raw "Add Redirect Rule" + ] + ] + ] + div [ _class "row" ] [ + div [ _class "col" ] [ + if List.isEmpty model then + div [ _id "rule_new" ] [ + p [ _class "text-muted text-center fst-italic" ] [ + raw "This web log has no redirect rules defined" + ] + ] + else + div [ _class "container g-0" ] [ + div [ _class "row mwl-table-heading" ] [ + div [ _class "col-5" ] [ raw "From" ] + div [ _class "col-5" ] [ raw "To" ] + div [ _class "col-2 text-center" ] [ raw "RegEx?" ] + ] + ] + div [ _class "row mwl-table-detail"; _id "rule_new" ] [] + form [ _method "post"; _class "container g-0"; _hxTarget "body" ] [ + antiCsrf app; yield! List.mapi ruleDetail model + ] + ] + ] + ] + p [ _class "mt-3 text-muted fst-italic text-center" ] [ + raw "This is an advanced feature; please " + a [ _href "https://bitbadger.solutions/open-source/myweblog/advanced.html#redirect-rules" + _target "_blank" ] [ + raw "read and understand the documentation on this feature" + ] + raw " before adding rules." + ] + ] +] + + +/// Edit a tag mapping +let tagMapEdit (model: EditTagMapModel) app = [ + h5 [ _class "my-3" ] [ txt app.PageTitle ] + form [ _hxPost (relUrl app "admin/settings/tag-mapping/save"); _method "post"; _class "container" + _hxTarget "#tagList"; _hxSwap $"{HxSwap.OuterHtml} show:window:top" ] [ + antiCsrf app + input [ _type "hidden"; _name "Id"; _value model.Id ] + div [ _class "row mb-3" ] [ + div [ _class "col-6 col-lg-4 offset-lg-2" ] [ + div [ _class "form-floating" ] [ + input [ _type "text"; _name "Tag"; _id "tag"; _class "form-control"; _placeholder "Tag"; _autofocus + _required; _value model.Tag ] + label [ _for "tag" ] [ raw "Tag" ] + ] + ] + div [ _class "col-6 col-lg-4" ] [ + div [ _class "form-floating" ] [ + input [ _type "text"; _name "UrlValue"; _id "urlValue"; _class "form-control" + _placeholder "URL Value"; _required; _value model.UrlValue ] + label [ _for "urlValue" ] [ raw "URL Value" ] + ] + ] + ] + div [ _class "row mb-3" ] [ + div [ _class "col text-center" ] [ + button [ _type "submit"; _class "btn btn-sm btn-primary" ] [ raw "Save Changes" ]; raw "   " + a [ _href (relUrl app "admin/settings/tag-mappings"); _class "btn btn-sm btn-secondary ms-3" ] [ + raw "Cancel" + ] + ] + ] + ] +] + + +/// Display a list of the web log's current tag mappings +let tagMapList (model: TagMap list) app = + let tagMapDetail (map: TagMap) = + let url = relUrl app $"admin/settings/tag-mapping/{map.Id}" + div [ _class "row mwl-table-detail"; _id $"tag_{map.Id}" ] [ + div [ _class "col no-wrap" ] [ + txt map.Tag; br [] + small [] [ + a [ _href $"{url}/edit"; _hxTarget $"#tag_{map.Id}" + _hxSwap $"{HxSwap.InnerHtml} show:#tag_{map.Id}:top" ] [ + raw "Edit" + ] + span [ _class "text-muted" ] [ raw " • " ] + a [ _href url; _hxDelete url; _class "text-danger" + _hxConfirm $"Are you sure you want to delete the mapping for “{map.Tag}”? This action cannot be undone." ] [ + raw "Delete" + ] + ] + ] + div [ _class "col" ] [ txt map.UrlValue ] + ] + div [ _id "tagList"; _class "container" ] [ + div [ _class "row" ] [ + div [ _class "col" ] [ + if List.isEmpty model then + div [ _id "tag_new" ] [ + p [ _class "text-muted text-center fst-italic" ] [ raw "This web log has no tag mappings" ] + ] + else + div [ _class "container g-0" ] [ + div [ _class "row mwl-table-heading" ] [ + div [ _class "col" ] [ raw "Tag" ] + div [ _class "col" ] [ raw "URL Value" ] + ] + ] + form [ _method "post"; _class "container g-0"; _hxTarget "#tagList"; _hxSwap HxSwap.OuterHtml ] [ + antiCsrf app + div [ _class "row mwl-table-detail"; _id "tag_new" ] [] + yield! List.map tagMapDetail model + ] + ] + ] + ] + |> List.singleton diff --git a/src/MyWebLog/Views/Helpers.fs b/src/MyWebLog/Views/Helpers.fs index 3e8f5d6..cd09280 100644 --- a/src/MyWebLog/Views/Helpers.fs +++ b/src/MyWebLog/Views/Helpers.fs @@ -98,6 +98,10 @@ let shortTime app (instant: Instant) = |> Option.defaultValue "--" |> txt +/// Display "Yes" or "No" based on the state of a boolean value +let yesOrNo value = + raw (if value then "Yes" else "No") + /// Functions for generating content in varying layouts module Layout = diff --git a/src/MyWebLog/Views/Post.fs b/src/MyWebLog/Views/Post.fs index 2da63c4..d3e5660 100644 --- a/src/MyWebLog/Views/Post.fs +++ b/src/MyWebLog/Views/Post.fs @@ -1,5 +1,6 @@ module MyWebLog.Views.Post +open Giraffe.Htmx.Common open Giraffe.ViewEngine open Giraffe.ViewEngine.Htmx open MyWebLog @@ -147,7 +148,7 @@ let chapterEdit (model: EditChapterModel) app = [ /// Display a list of chapters let chapterList withNew (model: ManageChaptersModel) app = - form [ _method "post"; _id "chapter_list"; _class "container mb-3"; _hxTarget "this"; _hxSwap "outerHTML" ] [ + form [ _method "post"; _id "chapter_list"; _class "container mb-3"; _hxTarget "this"; _hxSwap HxSwap.OuterHtml ] [ antiCsrf app input [ _type "hidden"; _name "Id"; _value model.Id ] div [ _class "row mwl-table-heading" ] [ @@ -170,7 +171,7 @@ let chapterList withNew (model: ManageChaptersModel) app = else let chapterUrl = relUrl app $"admin/post/{model.Id}/chapter/{idx}" a [ _href chapterUrl; _hxGet chapterUrl; _hxTarget $"#chapter{idx}" - _hxSwap $"innerHTML show:#chapter{idx}:top" ] [ + _hxSwap $"{HxSwap.InnerHtml} show:#chapter{idx}:top" ] [ raw "Edit" ] span [ _class "text-muted" ] [ raw " • " ] @@ -179,12 +180,8 @@ let chapterList withNew (model: ManageChaptersModel) app = ] ] ] - div [ _class "col-3 col-md-2 col-lg-1 text-center" ] [ - raw (match chapter.ImageUrl with Some _ -> "Y" | None -> "N") - ] - div [ _class "col-3 col-md-2 col-lg-1 text-center" ] [ - raw (match chapter.Location with Some _ -> "Y" | None -> "N") - ] + div [ _class "col-3 col-md-2 col-lg-1 text-center" ] [ yesOrNo (Option.isSome chapter.ImageUrl) ] + div [ _class "col-3 col-md-2 col-lg-1 text-center" ] [ yesOrNo (Option.isSome chapter.Location) ] ]) div [ _class "row pb-3"; _id "chapter-1" ] [ let newLink = relUrl app $"admin/post/{model.Id}/chapter/-1" diff --git a/src/MyWebLog/Views/User.fs b/src/MyWebLog/Views/User.fs index dedfab2..90c0116 100644 --- a/src/MyWebLog/Views/User.fs +++ b/src/MyWebLog/Views/User.fs @@ -1,5 +1,6 @@ module MyWebLog.Views.User +open Giraffe.Htmx.Common open Giraffe.ViewEngine open Giraffe.ViewEngine.Htmx open MyWebLog @@ -12,7 +13,7 @@ let edit (model: EditUserModel) app = div [ _class "col-12" ] [ h5 [ _class "my-3" ] [ txt app.PageTitle ] form [ _hxPost (relUrl app "admin/settings/user/save"); _method "post"; _class "container" - _hxTarget "#userList"; _hxSwap "outerHTML show:window:top" ] [ + _hxTarget "#userList"; _hxSwap $"{HxSwap.OuterHtml} show:window:top" ] [ antiCsrf app input [ _type "hidden"; _name "Id"; _value model.Id ] div [ _class "row" ] [ @@ -167,7 +168,8 @@ let userList (model: WebLogUser list) app = div [ _class "container g-0" ] [ div [ _class "row mwl-table-detail"; _id "user_new" ] [] ] - form [ _method "post"; _class "container g-0"; _hxTarget "this"; _hxSwap "outerHTML show:window:top" ] [ + form [ _method "post"; _class "container g-0"; _hxTarget "this" + _hxSwap $"{HxSwap.OuterHtml} show:window:top" ] [ antiCsrf app for user in model do div [ _class "row mwl-table-detail"; _id $"user_{user.Id}" ] [ @@ -183,7 +185,7 @@ let userList (model: WebLogUser list) app = let userUrl = relUrl app $"admin/settings/user/{user.Id}" small [] [ a [ _href $"{userUrl}/edit"; _hxTarget $"#user_{user.Id}" - _hxSwap $"innerHTML show:#user_{user.Id}:top" ] [ + _hxSwap $"{HxSwap.InnerHtml} show:#user_{user.Id}:top" ] [ raw "Edit" ] if app.UserId.Value <> user.Id then diff --git a/src/MyWebLog/appsettings.json b/src/MyWebLog/appsettings.json index 5956ea7..102b5a3 100644 --- a/src/MyWebLog/appsettings.json +++ b/src/MyWebLog/appsettings.json @@ -1,5 +1,4 @@ { - "Generator": "myWebLog 2.0", "Generator": "myWebLog 2.1", "Logging": { "LogLevel": { diff --git a/src/admin-theme/redirect-edit.liquid b/src/admin-theme/redirect-edit.liquid deleted file mode 100644 index 15b273b..0000000 --- a/src/admin-theme/redirect-edit.liquid +++ /dev/null @@ -1,48 +0,0 @@ -

    {% if model.rule_id < 0 %}Add{% else %}Edit{% endif %} Redirect Rule

    -{%- assign post_url = "admin/settings/redirect-rules/" | append: model.rule_id | relative_link -%} -
    - - -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    - {% if model.rule_id < 0 %} -
    -
    - -
    - - - - -
    -
    -
    - {% endif %} -
    -
    - - Cancel -
    -
    -
    diff --git a/src/admin-theme/redirect-list.liquid b/src/admin-theme/redirect-list.liquid deleted file mode 100644 index d78eaaf..0000000 --- a/src/admin-theme/redirect-list.liquid +++ /dev/null @@ -1,75 +0,0 @@ -

    {{ page_title }}

    -
    -

    - « Back to Settings -

    -
    - -
    -
    - {%- assign redir_count = redirections | size -%} - {% if redir_count > 0 -%} -
    -
    -
    From
    -
    To
    -
    RegEx?
    -
    -
    -
    -
    - - {% for redir in redirections -%} - {%- assign redir_id = "redir_" | append: forloop.index0 -%} -
    -
    - {{ redir.from }}
    - - {%- assign redir_url = "admin/settings/redirect-rules/" | append: forloop.index0 -%} - - Edit - - {% unless forloop.first %} - - {%- assign move_up = redir_url | append: "/up" | relative_link -%} - Move Up - {% endunless %} - {% unless forloop.last %} - - {%- assign move_down = redir_url | append: "/down" | relative_link -%} - Move Down - {% endunless %} - - {%- assign del_url = redir_url | append: "/delete" | relative_link -%} - - Delete - - -
    -
    {{ redir.to }}
    -
    {% if redir.is_regex %}Yes{% else %}No{% endif %}
    -
    - {%- endfor %} -
    - {%- else -%} -
    -

    This web log has no redirect rules defined -

    - {%- endif %} -
    -
    -
    -

    - This is an advanced feature; please - read and understand the documentation on this feature before adding rules. -

    diff --git a/src/admin-theme/settings.liquid b/src/admin-theme/settings.liquid index 10ac441..4f8e2d8 100644 --- a/src/admin-theme/settings.liquid +++ b/src/admin-theme/settings.liquid @@ -244,6 +244,6 @@ hx-target=#tag_new> Add a New Tag Mapping - {{ tag_mapping_list }} +
    diff --git a/src/admin-theme/tag-mapping-edit.liquid b/src/admin-theme/tag-mapping-edit.liquid deleted file mode 100644 index 1c5e8c7..0000000 --- a/src/admin-theme/tag-mapping-edit.liquid +++ /dev/null @@ -1,27 +0,0 @@ -
    {{ page_title }}
    -
    - - -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    -
    - - Cancel -
    -
    -
    diff --git a/src/admin-theme/tag-mapping-list-body.liquid b/src/admin-theme/tag-mapping-list-body.liquid deleted file mode 100644 index 6ed58e2..0000000 --- a/src/admin-theme/tag-mapping-list-body.liquid +++ /dev/null @@ -1,43 +0,0 @@ -
    -
    -
    - {%- assign map_count = mappings | size -%} - {% if map_count > 0 -%} -
    -
    -
    Tag
    -
    URL Value
    -
    -
    -
    - -
    - {% for map in mappings -%} - {%- assign map_id = mapping_ids | value: map.tag -%} -
    -
    - {{ map.tag }}
    - - {%- assign map_url = "admin/settings/tag-mapping/" | append: map_id -%} - - Edit - - - {%- assign map_del_link = map_url | append: "/delete" | relative_link -%} - - Delete - - -
    -
    {{ map.url_value }}
    -
    - {%- endfor %} -
    - {%- else -%} -

    This web log has no tag mappings

    - {%- endif %} -
    -
    -
    -- 2.45.1 From b99cd5b94bc3830ccb7e20feb963770dbb977ff0 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Tue, 12 Mar 2024 22:57:47 -0400 Subject: [PATCH 094/123] Migrate more templates to GVE --- src/MyWebLog.Domain/ViewModels.fs | 30 +--- src/MyWebLog.Tests/Domain/ViewModelsTests.fs | 12 -- src/MyWebLog/DotLiquidBespoke.fs | 14 +- src/MyWebLog/Handlers/Admin.fs | 85 +++++------- src/MyWebLog/Handlers/Helpers.fs | 16 +-- src/MyWebLog/Handlers/Page.fs | 18 ++- src/MyWebLog/Handlers/Post.fs | 38 +++--- src/MyWebLog/Handlers/Routes.fs | 14 +- src/MyWebLog/Handlers/User.fs | 15 +- src/MyWebLog/Views/Admin.fs | 88 ++++++++++++ src/MyWebLog/Views/Helpers.fs | 136 +++++++++++++++++++ src/MyWebLog/Views/User.fs | 117 +++++++++------- src/admin-theme/_theme-list-columns.liquid | 3 - src/admin-theme/_user-list-columns.liquid | 4 - src/admin-theme/admin-dashboard.liquid | 14 +- src/admin-theme/permalinks.liquid | 59 -------- src/admin-theme/revisions.liquid | 65 --------- src/admin-theme/settings.liquid | 13 -- src/admin-theme/theme-list-body.liquid | 33 ----- src/admin-theme/theme-upload.liquid | 30 ---- 20 files changed, 387 insertions(+), 417 deletions(-) delete mode 100644 src/admin-theme/_theme-list-columns.liquid delete mode 100644 src/admin-theme/_user-list-columns.liquid delete mode 100644 src/admin-theme/permalinks.liquid delete mode 100644 src/admin-theme/revisions.liquid delete mode 100644 src/admin-theme/theme-list-body.liquid delete mode 100644 src/admin-theme/theme-upload.liquid diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index bb411aa..1e802aa 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -204,26 +204,6 @@ type DisplayPage = { } -/// Information about a revision used for display -[] -type DisplayRevision = { - /// The as-of date/time for the revision - AsOf: DateTime - - /// The as-of date/time for the revision in the web log's local time zone - AsOfLocal: DateTime - - /// The format of the text of the revision - Format: string -} with - - /// Create a display revision from an actual revision - static member FromRevision (webLog: WebLog) (rev : Revision) = - { AsOf = rev.AsOf.ToDateTimeUtc() - AsOfLocal = webLog.LocalTime rev.AsOf - Format = rev.Text.SourceType } - - open System.IO /// Information about a theme used for display @@ -1180,22 +1160,22 @@ type ManageRevisionsModel = { CurrentTitle: string /// The revisions for the page or post - Revisions: DisplayRevision array + Revisions: Revision list } with /// Create a revision model from a page - static member FromPage webLog (page: Page) = + static member FromPage (page: Page) = { Id = string page.Id Entity = "page" CurrentTitle = page.Title - Revisions = page.Revisions |> List.map (DisplayRevision.FromRevision webLog) |> Array.ofList } + Revisions = page.Revisions } /// Create a revision model from a post - static member FromPost webLog (post: Post) = + static member FromPost (post: Post) = { Id = string post.Id Entity = "post" CurrentTitle = post.Title - Revisions = post.Revisions |> List.map (DisplayRevision.FromRevision webLog) |> Array.ofList } + Revisions = post.Revisions } /// View model for posts in a list diff --git a/src/MyWebLog.Tests/Domain/ViewModelsTests.fs b/src/MyWebLog.Tests/Domain/ViewModelsTests.fs index e00af60..a4bb736 100644 --- a/src/MyWebLog.Tests/Domain/ViewModelsTests.fs +++ b/src/MyWebLog.Tests/Domain/ViewModelsTests.fs @@ -179,17 +179,6 @@ let displayPageTests = testList "DisplayPage" [ ] ] -/// Unit tests for the DisplayRevision type -let displayRevisionTests = test "DisplayRevision.FromRevision succeeds" { - let model = - DisplayRevision.FromRevision - { WebLog.Empty with TimeZone = "Etc/GMT+1" } - { Text = Html "howdy"; AsOf = Noda.epoch } - Expect.equal model.AsOf (Noda.epoch.ToDateTimeUtc()) "AsOf not filled properly" - Expect.equal model.AsOfLocal ((Noda.epoch - Duration.FromHours 1).ToDateTimeUtc()) "AsOfLocal not filled properly" - Expect.equal model.Format "HTML" "Format not filled properly" -} - open System.IO /// Unit tests for the DisplayTheme type @@ -1346,7 +1335,6 @@ let all = testList "ViewModels" [ displayChapterTests displayCustomFeedTests displayPageTests - displayRevisionTests displayThemeTests displayUploadTests displayUserTests diff --git a/src/MyWebLog/DotLiquidBespoke.fs b/src/MyWebLog/DotLiquidBespoke.fs index 660c215..fe1c9d7 100644 --- a/src/MyWebLog/DotLiquidBespoke.fs +++ b/src/MyWebLog/DotLiquidBespoke.fs @@ -228,16 +228,10 @@ let register () = typeof; typeof; typeof; typeof; 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; typeof - typeof; typeof; typeof - typeof; typeof; typeof - typeof; typeof; typeof - 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; typeof diff --git a/src/MyWebLog/Handlers/Admin.fs b/src/MyWebLog/Handlers/Admin.fs index e04a9b1..d00239d 100644 --- a/src/MyWebLog/Handlers/Admin.fs +++ b/src/MyWebLog/Handlers/Admin.fs @@ -27,45 +27,35 @@ module Dashboard = ListedPages = listed Categories = cats TopLevelCategories = topCats } - return! adminPage "Dashboard" false (Views.Admin.dashboard model) next ctx + return! adminPage "Dashboard" false next ctx (Views.Admin.dashboard model) } // GET /admin/administration let admin : HttpHandler = requireAccess Administrator >=> fun next ctx -> task { - match! TemplateCache.get adminTheme "theme-list-body" ctx.Data with - | Ok bodyTemplate -> - let! themes = ctx.Data.Theme.All() - let cachedTemplates = TemplateCache.allNames () - let! hash = - hashForPage "myWebLog Administration" - |> withAntiCsrf ctx - |> addToHash "themes" ( - themes - |> List.map (DisplayTheme.FromTheme WebLogCache.isThemeInUse) - |> Array.ofList) - |> addToHash "cached_themes" ( - themes - |> Seq.ofList - |> Seq.map (fun it -> [| - string it.Id - it.Name - cachedTemplates - |> List.filter _.StartsWith(string it.Id) - |> List.length - |> string - |]) - |> Array.ofSeq) - |> addToHash "web_logs" ( - WebLogCache.all () - |> Seq.ofList - |> Seq.sortBy _.Name - |> Seq.map (fun it -> [| string it.Id; it.Name; it.UrlBase |]) - |> Array.ofSeq) - |> addViewContext ctx - return! - addToHash "theme_list" (bodyTemplate.Render hash) hash - |> adminView "admin-dashboard" next ctx - | Error message -> return! Error.server message next ctx + let! themes = ctx.Data.Theme.All() + let cachedTemplates = TemplateCache.allNames () + return! + hashForPage "myWebLog Administration" + |> withAntiCsrf ctx + |> addToHash "cached_themes" ( + themes + |> Seq.ofList + |> Seq.map (fun it -> [| + string it.Id + it.Name + cachedTemplates + |> List.filter _.StartsWith(string it.Id) + |> List.length + |> string + |]) + |> Array.ofSeq) + |> addToHash "web_logs" ( + WebLogCache.all () + |> Seq.ofList + |> Seq.sortBy _.Name + |> Seq.map (fun it -> [| string it.Id; it.Name; it.UrlBase |]) + |> Array.ofSeq) + |> adminView "admin-dashboard" next ctx } /// Redirect the user to the admin dashboard @@ -215,11 +205,11 @@ module RedirectRules = // GET /admin/settings/redirect-rules let all : HttpHandler = fun next ctx -> - adminPage "Redirect Rules" true (Views.Admin.redirectList ctx.WebLog.RedirectRules) next ctx + adminPage "Redirect Rules" true next ctx (Views.Admin.redirectList ctx.WebLog.RedirectRules) // GET /admin/settings/redirect-rules/[index] let edit idx : HttpHandler = fun next ctx -> - let titleAndModel = + let titleAndView = if idx = -1 then Some ("Add", Views.Admin.redirectEdit (EditRedirectRuleModel.FromRule -1 RedirectRule.Empty)) else @@ -228,8 +218,8 @@ module RedirectRules = None else Some ("Edit", (Views.Admin.redirectEdit (EditRedirectRuleModel.FromRule idx (List.item idx rules)))) - match titleAndModel with - | Some (title, model) -> adminBarePage $"{title} Redirect Rule" true model next ctx + match titleAndView with + | Some (title, view) -> adminBarePage $"{title} Redirect Rule" true next ctx view | None -> Error.notFound next ctx /// Update the web log's redirect rules in the database, the request web log, and the web log cache @@ -294,7 +284,7 @@ module TagMapping = // GET /admin/settings/tag-mappings let all : HttpHandler = fun next ctx -> task { let! mappings = ctx.Data.TagMap.FindByWebLog ctx.WebLog.Id - return! adminBarePage "Tag Mapping List" true (Views.Admin.tagMapList mappings) next ctx + return! adminBarePage "Tag Mapping List" true next ctx (Views.Admin.tagMapList mappings) } // GET /admin/settings/tag-mapping/{id}/edit @@ -306,9 +296,8 @@ module TagMapping = match! tagMap with | Some tm -> return! - adminBarePage - (if isNew then "Add Tag Mapping" else $"Mapping for {tm.Tag} Tag") true - (Views.Admin.tagMapEdit (EditTagMapModel.FromMapping tm)) next ctx + Views.Admin.tagMapEdit (EditTagMapModel.FromMapping tm) + |> adminBarePage (if isNew then "Add Tag Mapping" else $"Mapping for {tm.Tag} Tag") true next ctx | None -> return! Error.notFound next ctx } @@ -349,17 +338,13 @@ module Theme = let all : HttpHandler = requireAccess Administrator >=> fun next ctx -> task { let! themes = ctx.Data.Theme.All () return! - hashForPage "Themes" - |> withAntiCsrf ctx - |> addToHash "themes" (themes |> List.map (DisplayTheme.FromTheme WebLogCache.isThemeInUse) |> Array.ofList) - |> adminBareView "theme-list-body" next ctx + Views.Admin.themeList (List.map (DisplayTheme.FromTheme WebLogCache.isThemeInUse) themes) + |> adminBarePage "Themes" true next ctx } // GET /admin/theme/new let add : HttpHandler = requireAccess Administrator >=> fun next ctx -> - hashForPage "Upload a Theme File" - |> withAntiCsrf ctx - |> adminBareView "theme-upload" next ctx + adminBarePage "Upload a Theme File" true next ctx Views.Admin.themeUpload /// Update the name and version for a theme based on the version.txt file, if present let private updateNameAndVersion (theme: Theme) (zip: ZipArchive) = backgroundTask { diff --git a/src/MyWebLog/Handlers/Helpers.fs b/src/MyWebLog/Handlers/Helpers.fs index 00f7274..e78a580 100644 --- a/src/MyWebLog/Handlers/Helpers.fs +++ b/src/MyWebLog/Handlers/Helpers.fs @@ -282,8 +282,9 @@ module Error = let notAuthorized : HttpHandler = fun next ctx -> if ctx.Request.Method = "GET" then let redirectUrl = $"user/log-on?returnUrl={WebUtility.UrlEncode ctx.Request.Path}" - if isHtmx ctx then (withHxRedirect redirectUrl >=> redirectToGet redirectUrl) next ctx - else redirectToGet redirectUrl next ctx + (next, ctx) + ||> if isHtmx ctx then withHxRedirect redirectUrl >=> withHxRetarget "body" >=> redirectToGet redirectUrl + else redirectToGet redirectUrl else if isHtmx ctx then let messages = [| @@ -370,7 +371,7 @@ let adminBareView template = bareForTheme adminTheme template /// Display a page for an admin endpoint -let adminPage pageTitle includeCsrf (content: AppViewContext -> XmlNode list) : HttpHandler = fun next ctx -> task { +let adminPage pageTitle includeCsrf next ctx (content: AppViewContext -> XmlNode list) = task { let! messages = getCurrentMessages ctx let appCtx = generateViewContext pageTitle messages includeCsrf ctx let layout = if isHtmx ctx then Layout.partial else Layout.full @@ -378,7 +379,7 @@ let adminPage pageTitle includeCsrf (content: AppViewContext -> XmlNode list) : } /// Display a bare page for an admin endpoint -let adminBarePage pageTitle includeCsrf (content: AppViewContext -> XmlNode list) : HttpHandler = fun next ctx -> task { +let adminBarePage pageTitle includeCsrf next ctx (content: AppViewContext -> XmlNode list) = task { let! messages = getCurrentMessages ctx let appCtx = generateViewContext pageTitle messages includeCsrf ctx return! @@ -471,13 +472,12 @@ let getCategoryIds slug ctx = |> Seq.map (fun c -> CategoryId c.Id) |> List.ofSeq -open System -open System.Globalization open NodaTime /// Parse a date/time to UTC -let parseToUtc (date: string) = - Instant.FromDateTimeUtc(DateTime.Parse(date, null, DateTimeStyles.AdjustToUniversal)) +let parseToUtc (date: string) : Instant = + let result = roundTrip.Parse date + if result.Success then result.Value else raise result.Exception open Microsoft.Extensions.DependencyInjection open Microsoft.Extensions.Logging diff --git a/src/MyWebLog/Handlers/Page.fs b/src/MyWebLog/Handlers/Page.fs index 28b8074..5cf619b 100644 --- a/src/MyWebLog/Handlers/Page.fs +++ b/src/MyWebLog/Handlers/Page.fs @@ -66,10 +66,9 @@ let editPermalinks pgId : HttpHandler = requireAccess Author >=> fun next ctx -> match! ctx.Data.Page.FindFullById (PageId pgId) ctx.WebLog.Id with | Some pg when canEdit pg.AuthorId ctx -> return! - hashForPage "Manage Prior Permalinks" - |> withAntiCsrf ctx - |> addToHash ViewContext.Model (ManagePermalinksModel.FromPage pg) - |> adminView "permalinks" next ctx + ManagePermalinksModel.FromPage pg + |> Views.Helpers.managePermalinks + |> adminPage "Manage Prior Permalinks" true next ctx | Some _ -> return! Error.notAuthorized next ctx | None -> return! Error.notFound next ctx } @@ -95,15 +94,14 @@ let editRevisions pgId : HttpHandler = requireAccess Author >=> fun next ctx -> match! ctx.Data.Page.FindFullById (PageId pgId) ctx.WebLog.Id with | Some pg when canEdit pg.AuthorId ctx -> return! - hashForPage "Manage Page Revisions" - |> withAntiCsrf ctx - |> addToHash ViewContext.Model (ManageRevisionsModel.FromPage ctx.WebLog pg) - |> adminView "revisions" next ctx + ManageRevisionsModel.FromPage pg + |> Views.Helpers.manageRevisions + |> adminPage "Manage Page Revisions" true next ctx | Some _ -> return! Error.notAuthorized next ctx | None -> return! Error.notFound next ctx } -// GET /admin/page/{id}/revisions/purge +// DELETE /admin/page/{id}/revisions let purgeRevisions pgId : HttpHandler = requireAccess Author >=> fun next ctx -> task { let data = ctx.Data match! data.Page.FindFullById (PageId pgId) ctx.WebLog.Id with @@ -158,7 +156,7 @@ let restoreRevision (pgId, revDate) : HttpHandler = requireAccess Author >=> fun | _, None -> return! Error.notFound next ctx } -// POST /admin/page/{id}/revision/{revision-date}/delete +// DELETE /admin/page/{id}/revision/{revision-date} let deleteRevision (pgId, revDate) : HttpHandler = requireAccess Author >=> fun next ctx -> task { match! findPageRevision pgId revDate ctx with | Some pg, Some rev when canEdit pg.AuthorId ctx -> diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index 0555999..8e0cdb4 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -254,7 +254,7 @@ let all pageNbr : HttpHandler = requireAccess Author >=> fun next ctx -> task { let data = ctx.Data let! posts = data.Post.FindPageOfPosts ctx.WebLog.Id pageNbr 25 let! hash = preparePostList ctx.WebLog posts AdminList "" pageNbr 25 data - return! adminPage "Posts" true (Views.Post.list (hash[ViewContext.Model] :?> PostDisplay)) next ctx + return! adminPage "Posts" true next ctx (Views.Post.list (hash[ViewContext.Model] :?> PostDisplay)) } // GET /admin/post/{id}/edit @@ -305,10 +305,9 @@ let editPermalinks postId : HttpHandler = requireAccess Author >=> fun next ctx match! ctx.Data.Post.FindFullById (PostId postId) ctx.WebLog.Id with | Some post when canEdit post.AuthorId ctx -> return! - hashForPage "Manage Prior Permalinks" - |> withAntiCsrf ctx - |> addToHash ViewContext.Model (ManagePermalinksModel.FromPost post) - |> adminView "permalinks" next ctx + ManagePermalinksModel.FromPost post + |> Views.Helpers.managePermalinks + |> adminPage "Manage Prior Permalinks" true next ctx | Some _ -> return! Error.notAuthorized next ctx | None -> return! Error.notFound next ctx } @@ -334,15 +333,14 @@ let editRevisions postId : HttpHandler = requireAccess Author >=> fun next ctx - match! ctx.Data.Post.FindFullById (PostId postId) ctx.WebLog.Id with | Some post when canEdit post.AuthorId ctx -> return! - hashForPage "Manage Post Revisions" - |> withAntiCsrf ctx - |> addToHash ViewContext.Model (ManageRevisionsModel.FromPost ctx.WebLog post) - |> adminView "revisions" next ctx + ManageRevisionsModel.FromPost post + |> Views.Helpers.manageRevisions + |> adminPage "Manage Post Revisions" true next ctx | Some _ -> return! Error.notAuthorized next ctx | None -> return! Error.notFound next ctx } -// GET /admin/post/{id}/revisions/purge +// DELETE /admin/post/{id}/revisions let purgeRevisions postId : HttpHandler = requireAccess Author >=> fun next ctx -> task { let data = ctx.Data match! data.Post.FindFullById (PostId postId) ctx.WebLog.Id with @@ -398,7 +396,7 @@ let restoreRevision (postId, revDate) : HttpHandler = requireAccess Author >=> f | _, None -> return! Error.notFound next ctx } -// POST /admin/post/{id}/revision/{revision-date}/delete +// DELETE /admin/post/{id}/revision/{revision-date} let deleteRevision (postId, revDate) : HttpHandler = requireAccess Author >=> fun next ctx -> task { match! findPostRevision postId revDate ctx with | Some post, Some rev when canEdit post.AuthorId ctx -> @@ -418,7 +416,8 @@ let manageChapters postId : HttpHandler = requireAccess Author >=> fun next ctx && Option.isSome post.Episode.Value.Chapters && canEdit post.AuthorId ctx -> return! - adminPage "Manage Chapters" true (Views.Post.chapters false (ManageChaptersModel.Create post)) next ctx + Views.Post.chapters false (ManageChaptersModel.Create post) + |> adminPage "Manage Chapters" true next ctx | Some _ | None -> return! Error.notFound next ctx } @@ -437,9 +436,8 @@ let editChapter (postId, index) : HttpHandler = requireAccess Author >=> fun nex match chapter with | Some chap -> return! - adminBarePage - (if index = -1 then "Add a Chapter" else "Edit Chapter") true - (Views.Post.chapterEdit (EditChapterModel.FromChapter post.Id index chap)) next ctx + Views.Post.chapterEdit (EditChapterModel.FromChapter post.Id index chap) + |> adminBarePage (if index = -1 then "Add a Chapter" else "Edit Chapter") true next ctx | None -> return! Error.notFound next ctx | Some _ | None -> return! Error.notFound next ctx } @@ -466,9 +464,8 @@ let saveChapter (postId, index) : HttpHandler = requireAccess Author >=> fun nex do! data.Post.Update updatedPost do! addMessage ctx { UserMessage.Success with Message = "Chapter saved successfully" } return! - adminPage - "Manage Chapters" true - (Views.Post.chapterList form.AddAnother (ManageChaptersModel.Create updatedPost)) next ctx + Views.Post.chapterList form.AddAnother (ManageChaptersModel.Create updatedPost) + |> adminPage "Manage Chapters" true next ctx with | ex -> return! Error.server ex.Message next ctx else return! Error.notFound next ctx @@ -491,9 +488,8 @@ let deleteChapter (postId, index) : HttpHandler = requireAccess Author >=> fun n do! data.Post.Update updatedPost do! addMessage ctx { UserMessage.Success with Message = "Chapter deleted successfully" } return! - adminPage - "Manage Chapters" true (Views.Post.chapterList false (ManageChaptersModel.Create updatedPost)) next - ctx + Views.Post.chapterList false (ManageChaptersModel.Create updatedPost) + |> adminPage "Manage Chapters" true next ctx else return! Error.notFound next ctx | Some _ | None -> return! Error.notFound next ctx } diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index a4ef6f9..f0db9ca 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -176,17 +176,13 @@ let router : HttpHandler = choose [ route "/save" >=> Page.save route "/permalinks" >=> Page.savePermalinks routef "/%s/delete" Page.delete - routef "/%s/revision/%s/delete" Page.deleteRevision routef "/%s/revision/%s/restore" Page.restoreRevision - routef "/%s/revisions/purge" Page.purgeRevisions ]) subRoute "/post" (choose [ route "/save" >=> Post.save route "/permalinks" >=> Post.savePermalinks routef "/%s/chapter/%i" Post.saveChapter - routef "/%s/revision/%s/delete" Post.deleteRevision routef "/%s/revision/%s/restore" Post.restoreRevision - routef "/%s/revisions/purge" Post.purgeRevisions ]) subRoute "/settings" (requireAccess WebLogAdmin >=> choose [ route "" >=> Admin.WebLog.saveSettings @@ -214,9 +210,15 @@ let router : HttpHandler = choose [ ]) ] DELETE >=> validateCsrf >=> choose [ + subRoute "/page" (choose [ + routef "/%s/revision/%s" Page.deleteRevision + routef "/%s/revisions" Page.purgeRevisions + ]) subRoute "/post" (choose [ - routef "/%s" Post.delete - routef "/%s/chapter/%i" Post.deleteChapter + routef "/%s" Post.delete + routef "/%s/chapter/%i" Post.deleteChapter + routef "/%s/revision/%s" Post.deleteRevision + routef "/%s/revisions" Post.purgeRevisions ]) subRoute "/settings" (requireAccess WebLogAdmin >=> choose [ routef "/user/%s" User.delete diff --git a/src/MyWebLog/Handlers/User.fs b/src/MyWebLog/Handlers/User.fs index fbd1734..5f972ac 100644 --- a/src/MyWebLog/Handlers/User.fs +++ b/src/MyWebLog/Handlers/User.fs @@ -35,7 +35,7 @@ let logOn returnUrl : HttpHandler = fun next ctx -> match returnUrl with | Some _ -> returnUrl | None -> if ctx.Request.Query.ContainsKey "returnUrl" then Some ctx.Request.Query["returnUrl"].[0] else None - adminPage "Log On" true (Views.User.logOn { LogOnModel.Empty with ReturnTo = returnTo }) next ctx + adminPage "Log On" true next ctx (Views.User.logOn { LogOnModel.Empty with ReturnTo = returnTo }) open System.Security.Claims @@ -91,12 +91,12 @@ let private goAway : HttpHandler = RequestErrors.BAD_REQUEST "really?" // GET /admin/settings/users let all : HttpHandler = fun next ctx -> task { let! users = ctx.Data.WebLogUser.FindByWebLog ctx.WebLog.Id - return! adminBarePage "User Administration" true (Views.User.userList users) next ctx + return! adminBarePage "User Administration" true next ctx (Views.User.userList users) } /// Show the edit user page let private showEdit (model: EditUserModel) : HttpHandler = fun next ctx -> - adminBarePage (if model.IsNew then "Add a New User" else "Edit User") true (Views.User.edit model) next ctx + adminBarePage (if model.IsNew then "Add a New User" else "Edit User") true next ctx (Views.User.edit model) // GET /admin/settings/user/{id}/edit let edit usrId : HttpHandler = fun next ctx -> task { @@ -137,7 +137,9 @@ let delete userId : HttpHandler = fun next ctx -> task { let myInfo : HttpHandler = requireAccess Author >=> fun next ctx -> task { match! ctx.Data.WebLogUser.FindById ctx.UserId ctx.WebLog.Id with | Some user -> - return! adminPage "Edit Your Information" true (Views.User.myInfo (EditMyInfoModel.FromUser user) user) next ctx + return! + Views.User.myInfo (EditMyInfoModel.FromUser user) user + |> adminPage "Edit Your Information" true next ctx | None -> return! Error.notFound next ctx } @@ -161,9 +163,8 @@ let saveMyInfo : HttpHandler = requireAccess Author >=> fun next ctx -> task { | Some user -> do! addMessage ctx { UserMessage.Error with Message = "Passwords did not match; no updates made" } return! - adminPage - "Edit Your Information" true - (Views.User.myInfo { model with NewPassword = ""; NewPasswordConfirm = "" } user) next ctx + Views.User.myInfo { model with NewPassword = ""; NewPasswordConfirm = "" } user + |> adminPage "Edit Your Information" true next ctx | None -> return! Error.notFound next ctx } diff --git a/src/MyWebLog/Views/Admin.fs b/src/MyWebLog/Views/Admin.fs index 7e4aac1..f773b46 100644 --- a/src/MyWebLog/Views/Admin.fs +++ b/src/MyWebLog/Views/Admin.fs @@ -293,3 +293,91 @@ let tagMapList (model: TagMap list) app = ] ] |> List.singleton + + +/// Display a list of themes +let themeList (model: DisplayTheme list) app = + let themeCol = "col-12 col-md-6" + let slugCol = "d-none d-md-block col-md-3" + let tmplCol = "d-none d-md-block col-md-3" + div [ _id "theme_panel" ] [ + a [ _href (relUrl app "admin/theme/new"); _class "btn btn-primary btn-sm mb-3"; _hxTarget "#theme_new" ] [ + raw "Upload a New Theme" + ] + div [ _class "container g-0" ] [ + div [ _class "row mwl-table-heading" ] [ + div [ _class themeCol ] [ raw "Theme" ] + div [ _class slugCol ] [ raw "Slug" ] + div [ _class tmplCol ] [ raw "Templates" ] + ] + ] + div [ _class "row mwl-table-detail"; _id "theme_new" ] [] + form [ _method "post"; _id "themeList"; _class "container g-0"; _hxTarget "#theme_panel" + _hxSwap $"{HxSwap.OuterHtml} show:window:top" ] [ + antiCsrf app + for theme in model do + let url = relUrl app $"admin/theme/{theme.Id}" + div [ _class "row mwl-table-detail"; _id $"theme_{theme.Id}" ] [ + div [ _class $"{themeCol} no-wrap" ] [ + txt theme.Name + if theme.IsInUse then span [ _class "badge bg-primary ms-2" ] [ raw "IN USE" ] + if not theme.IsOnDisk then + span [ _class "badge bg-warning text-dark ms-2" ] [ raw "NOT ON DISK" ] + br [] + small [] [ + span [ _class "text-muted" ] [ txt $"v{theme.Version}" ] + if not (theme.IsInUse || theme.Id = "default") then + span [ _class "text-muted" ] [ raw " • " ] + a [ _href url; _hxDelete url; _class "text-danger" + _hxConfirm $"Are you sure you want to delete the theme “{theme.Name}”? This action cannot be undone." ] [ + raw "Delete" + ] + span [ _class "d-md-none text-muted" ] [ + br []; raw "Slug: "; txt theme.Id; raw $" • {theme.TemplateCount} Templates" + ] + ] + ] + div [ _class slugCol ] [ txt (string theme.Id) ] + div [ _class tmplCol ] [ txt (string theme.TemplateCount) ] + ] + ] + ] + |> List.singleton + + +/// Form to allow a theme to be uploaded +let themeUpload app = + div [ _class "col" ] [ + h5 [ _class "mt-2" ] [ raw app.PageTitle ] + form [ _action (relUrl app "admin/theme/new"); _method "post"; _class "container" + _enctype "multipart/form-data"; _hxNoBoost ] [ + antiCsrf app + div [ _class "row " ] [ + div [ _class "col-12 col-sm-6 pb-3" ] [ + div [ _class "form-floating" ] [ + input [ _type "file"; _id "file"; _name "file"; _class "form-control"; _accept ".zip" + _placeholder "Theme File"; _required ] + label [ _for "file" ] [ raw "Theme File" ] + ] + ] + div [ _class "col-12 col-sm-6 pb-3 d-flex justify-content-center align-items-center" ] [ + div [ _class "form-check form-switch pb-2" ] [ + input [ _type "checkbox"; _name "DoOverwrite"; _id "doOverwrite"; _class "form-check-input" + _value "true" ] + label [ _for "doOverwrite"; _class "form-check-label" ] [ raw "Overwrite" ] + ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col text-center" ] [ + button [ _type "submit"; _class "btn btn-sm btn-primary" ] [ raw "Upload Theme" ]; raw "   " + button [ _type "button"; _class "btn btn-sm btn-secondary ms-3" + _onclick "document.getElementById('theme_new').innerHTML = ''" ] [ + raw "Cancel" + ] + ] + ] + ] + ] + |> List.singleton + \ No newline at end of file diff --git a/src/MyWebLog/Views/Helpers.fs b/src/MyWebLog/Views/Helpers.fs index cd09280..ab28e95 100644 --- a/src/MyWebLog/Views/Helpers.fs +++ b/src/MyWebLog/Views/Helpers.fs @@ -232,3 +232,139 @@ module Layout = title [] [] yield! content app ] + + +// ~~ SHARED TEMPLATES BETWEEN POSTS AND PAGES +open Giraffe.Htmx.Common + +/// The round-trip instant pattern +let roundTrip = InstantPattern.CreateWithInvariantCulture "uuuu'-'MM'-'dd'T'HH':'mm':'ss'.'fffffff" + +/// Capitalize the first letter in the given string +let private capitalize (it: string) = + $"{(string it[0]).ToUpper()}{it[1..]}" + +/// Form to manage permalinks for pages or posts +let managePermalinks (model: ManagePermalinksModel) app = [ + let baseUrl = relUrl app $"admin/{model.Entity}/" + let linkDetail idx link = + div [ _id $"link_%i{idx}"; _class "row mb-3" ] [ + div [ _class "col-1 text-center align-self-center" ] [ + button [ _type "button"; _class "btn btn-sm btn-danger" + _onclick $"Admin.removePermalink({idx})" ] [ + raw "−" + ] + ] + div [ _class "col-11" ] [ + div [ _class "form-floating" ] [ + input [ _type "text"; _name "Prior"; _id $"prior_{idx}"; _class "form-control"; _placeholder "Link" + _value link ] + label [ _for $"prior_{idx}" ] [ raw "Link" ] + ] + ] + ] + h2 [ _class "my-3" ] [ raw app.PageTitle ] + article [] [ + form [ _action $"{baseUrl}permalinks"; _method "post"; _class "container" ] [ + antiCsrf app + input [ _type "hidden"; _name "Id"; _value model.Id ] + div [ _class "row" ] [ + div [ _class "col" ] [ + p [ _style "line-height:1.2rem;" ] [ + strong [] [ txt model.CurrentTitle ]; br [] + small [ _class "text-muted" ] [ + span [ _class "fst-italic" ] [ txt model.CurrentPermalink ]; br [] + a [ _href $"{baseUrl}{model.Id}/edit" ] [ + raw $"« Back to Edit {capitalize model.Entity}" + ] + ] + ] + ] + ] + div [ _class "row mb-3" ] [ + div [ _class "col" ] [ + button [ _type "button"; _class "btn btn-sm btn-secondary"; _onclick "Admin.addPermalink()" ] [ + raw "Add a Permalink" + ] + ] + ] + div [ _class "row mb-3" ] [ + div [ _class "col" ] [ + div [ _id "permalinks"; _class "container g-0" ] [ + yield! Array.mapi linkDetail model.Prior + script [] [ + raw """document.addEventListener(\"DOMContentLoaded\", """ + raw $"() => Admin.setPermalinkIndex({model.Prior.Length}))" + ] + ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col " ] [ + button [ _type "submit"; _class "btn btn-primary" ] [ raw "Save Changes" ] + ] + ] + ] + ] +] + +/// Form to manage revisions for pages or posts +let manageRevisions (model: ManageRevisionsModel) app = [ + let revUrlBase = relUrl app $"admin/{model.Entity}/{model.Id}/revision" + let revDetail idx (rev: Revision) = + let asOfString = roundTrip.Format rev.AsOf + let asOfId = $"""rev_{asOfString.Replace(".", "_").Replace(":", "-")}""" + div [ _id asOfId; _class "row pb-3 mwl-table-detail" ] [ + div [ _class "col-12 mb-1" ] [ + longDate app rev.AsOf; raw " at "; shortTime app rev.AsOf; raw " " + span [ _class "badge bg-secondary text-uppercase ms-2" ] [ txt (string rev.Text.SourceType) ] + if idx = 0 then span [ _class "badge bg-primary text-uppercase ms-2" ] [ raw "Current Revision" ] + br [] + if idx > 0 then + let revUrlPrefix = $"{revUrlBase}/{asOfString}" + let revRestore = $"{revUrlPrefix}/restore" + small [] [ + a [ _href $"{revUrlPrefix}/preview"; _hxTarget $"#{asOfId}_preview" ] [ raw "Preview" ] + span [ _class "text-muted" ] [ raw " • " ] + a [ _href revRestore; _hxPost revRestore ] [ raw "Restore as Current" ] + span [ _class "text-muted" ] [ raw " • " ] + a [ _href revUrlPrefix; _hxDelete revUrlPrefix; _hxTarget $"#{asOfId}" + _hxSwap HxSwap.OuterHtml; _class "text-danger" ] [ + raw "Delete" + ] + ] + ] + if idx > 0 then div [ _id $"{asOfId}_preview"; _class "col-12" ] [] + ] + + h2 [ _class "my-3" ] [ raw app.PageTitle ] + article [] [ + form [ _method "post"; _hxTarget "body"; _class "container mb-3" ] [ + antiCsrf app + input [ _type "hidden"; _name "Id"; _value model.Id ] + div [ _class "row" ] [ + div [ _class "col" ] [ + p [ _style "line-height:1.2rem;" ] [ + strong [] [ txt model.CurrentTitle ]; br [] + small [ _class "text-muted" ] [ + a [ _href (relUrl app $"admin/{model.Entity}/{model.Id}/edit") ] [ + raw $"« Back to Edit {(string model.Entity[0]).ToUpper()}{model.Entity[1..]}" + ] + ] + ] + ] + ] + if model.Revisions.Length > 1 then + div [ _class "row mb-3" ] [ + div [ _class "col" ] [ + button [ _type "button"; _class "btn btn-sm btn-danger"; _hxDelete $"{revUrlBase}s/purge" + _hxConfirm "This will remove all revisions but the current one; are you sure this is what you wish to do?" ] [ + raw "Delete All Prior Revisions" + ] + ] + ] + div [ _class "row mwl-table-heading" ] [ div [ _class "col" ] [ raw "Revision" ] ] + yield! List.mapi revDetail model.Revisions + ] + ] +] diff --git a/src/MyWebLog/Views/User.fs b/src/MyWebLog/Views/User.fs index 90c0116..98c9dc2 100644 --- a/src/MyWebLog/Views/User.fs +++ b/src/MyWebLog/Views/User.fs @@ -13,7 +13,7 @@ let edit (model: EditUserModel) app = div [ _class "col-12" ] [ h5 [ _class "my-3" ] [ txt app.PageTitle ] form [ _hxPost (relUrl app "admin/settings/user/save"); _method "post"; _class "container" - _hxTarget "#userList"; _hxSwap $"{HxSwap.OuterHtml} show:window:top" ] [ + _hxTarget "#user_panel"; _hxSwap $"{HxSwap.OuterHtml} show:window:top" ] [ antiCsrf app input [ _type "hidden"; _name "Id"; _value model.Id ] div [ _class "row" ] [ @@ -163,56 +163,77 @@ let logOn (model: LogOnModel) (app: AppViewContext) = [ /// The list of users for a web log (part of web log settings page) let userList (model: WebLogUser list) app = - let badge = "ms-2 badge bg" - div [ _id "userList" ] [ - div [ _class "container g-0" ] [ - div [ _class "row mwl-table-detail"; _id "user_new" ] [] - ] - form [ _method "post"; _class "container g-0"; _hxTarget "this" - _hxSwap $"{HxSwap.OuterHtml} show:window:top" ] [ - antiCsrf app - for user in model do - div [ _class "row mwl-table-detail"; _id $"user_{user.Id}" ] [ - div [ _class "col-12 col-md-4 col-xl-3 no-wrap" ] [ - txt user.PreferredName; raw " " - match user.AccessLevel with - | Administrator -> span [ _class $"{badge}-success" ] [ raw "ADMINISTRATOR" ] - | WebLogAdmin -> span [ _class $"{badge}-primary" ] [ raw "WEB LOG ADMIN" ] - | Editor -> span [ _class $"{badge}-secondary" ] [ raw "EDITOR" ] - | Author -> span [ _class $"{badge}-dark" ] [ raw "AUTHOR" ] - br [] - if app.IsAdministrator || (app.IsWebLogAdmin && not (user.AccessLevel = Administrator)) then - let userUrl = relUrl app $"admin/settings/user/{user.Id}" - small [] [ - a [ _href $"{userUrl}/edit"; _hxTarget $"#user_{user.Id}" - _hxSwap $"{HxSwap.InnerHtml} show:#user_{user.Id}:top" ] [ - raw "Edit" - ] - if app.UserId.Value <> user.Id then - span [ _class "text-muted" ] [ raw " • " ] - a [ _href userUrl; _hxDelete userUrl; _class "text-danger" - _hxConfirm $"Are you sure you want to delete the user “{user.PreferredName}”? This action cannot be undone. (This action will not succeed if the user has authored any posts or pages.)" ] [ - raw "Delete" - ] + let userCol = "col-12 col-md-4 col-xl-3" + let emailCol = "col-12 col-md-4 col-xl-4" + let cre8Col = "d-none d-xl-block col-xl-2" + let lastCol = "col-12 col-md-4 col-xl-3" + let badge = "ms-2 badge bg" + let userDetail (user: WebLogUser) = + div [ _class "row mwl-table-detail"; _id $"user_{user.Id}" ] [ + div [ _class $"{userCol} no-wrap" ] [ + txt user.PreferredName; raw " " + match user.AccessLevel with + | Administrator -> span [ _class $"{badge}-success" ] [ raw "ADMINISTRATOR" ] + | WebLogAdmin -> span [ _class $"{badge}-primary" ] [ raw "WEB LOG ADMIN" ] + | Editor -> span [ _class $"{badge}-secondary" ] [ raw "EDITOR" ] + | Author -> span [ _class $"{badge}-dark" ] [ raw "AUTHOR" ] + br [] + if app.IsAdministrator || (app.IsWebLogAdmin && not (user.AccessLevel = Administrator)) then + let userUrl = relUrl app $"admin/settings/user/{user.Id}" + small [] [ + a [ _href $"{userUrl}/edit"; _hxTarget $"#user_{user.Id}" + _hxSwap $"{HxSwap.InnerHtml} show:#user_{user.Id}:top" ] [ + raw "Edit" + ] + if app.UserId.Value <> user.Id then + span [ _class "text-muted" ] [ raw " • " ] + a [ _href userUrl; _hxDelete userUrl; _class "text-danger" + _hxConfirm $"Are you sure you want to delete the user “{user.PreferredName}”? This action cannot be undone. (This action will not succeed if the user has authored any posts or pages.)" ] [ + raw "Delete" ] ] - div [ _class "col-12 col-md-4 col-xl-4" ] [ - txt $"{user.FirstName} {user.LastName}"; br [] - small [ _class "text-muted" ] [ - txt user.Email - if Option.isSome user.Url then - br []; txt user.Url.Value - ] - ] - div [ _class "d-none d-xl-block col-xl-2" ] [ - if user.CreatedOn = Noda.epoch then raw "N/A" else longDate app user.CreatedOn - ] - div [ _class "col-12 col-md-4 col-xl-3" ] [ - match user.LastSeenOn with - | Some it -> longDate app it; raw " at "; shortTime app it - | None -> raw "--" - ] + ] + div [ _class emailCol ] [ + txt $"{user.FirstName} {user.LastName}"; br [] + small [ _class "text-muted" ] [ + txt user.Email + if Option.isSome user.Url then + br []; txt user.Url.Value ] + ] + div [ _class "d-none d-xl-block col-xl-2" ] [ + if user.CreatedOn = Noda.epoch then raw "N/A" else longDate app user.CreatedOn + ] + div [ _class "col-12 col-md-4 col-xl-3" ] [ + match user.LastSeenOn with + | Some it -> longDate app it; raw " at "; shortTime app it + | None -> raw "--" + ] + ] + div [ _id "user_panel" ] [ + a [ _href (relUrl app "admin/settings/user/new/edit"); _class "btn btn-primary btn-sm mb-3" + _hxTarget "#user_new" ] [ + raw "Add a New User" + ] + div [ _class "container g-0" ] [ + div [ _class "row mwl-table-heading" ] [ + div [ _class userCol ] [ + raw "User"; span [ _class "d-md-none" ] [ raw "; Full Name / E-mail; Last Log On" ] + ] + div [ _class $"{emailCol} d-none d-md-inline-block" ] [ raw "Full Name / E-mail" ] + div [ _class cre8Col ] [ raw "Created" ] + div [ _class $"{lastCol} d-none d-md-block" ] [ raw "Last Log On" ] + ] + ] + div [ _id "userList" ] [ + div [ _class "container g-0" ] [ + div [ _class "row mwl-table-detail"; _id "user_new" ] [] + ] + form [ _method "post"; _class "container g-0"; _hxTarget "#user_panel" + _hxSwap $"{HxSwap.OuterHtml} show:window:top" ] [ + antiCsrf app + yield! List.map userDetail model + ] ] ] |> List.singleton diff --git a/src/admin-theme/_theme-list-columns.liquid b/src/admin-theme/_theme-list-columns.liquid deleted file mode 100644 index 18b9282..0000000 --- a/src/admin-theme/_theme-list-columns.liquid +++ /dev/null @@ -1,3 +0,0 @@ -{%- assign theme_col = "col-12 col-md-6" -%} -{%- assign slug_col = "d-none d-md-block col-md-3" -%} -{%- assign tmpl_col = "d-none d-md-block col-md-3" -%} diff --git a/src/admin-theme/_user-list-columns.liquid b/src/admin-theme/_user-list-columns.liquid deleted file mode 100644 index 74b6626..0000000 --- a/src/admin-theme/_user-list-columns.liquid +++ /dev/null @@ -1,4 +0,0 @@ -{%- assign user_col = "col-12 col-md-4 col-xl-3" -%} -{%- assign email_col = "col-12 col-md-4 col-xl-4" -%} -{%- assign cre8_col = "d-none d-xl-block col-xl-2" -%} -{%- assign last_col = "col-12 col-md-4 col-xl-3" -%} diff --git a/src/admin-theme/admin-dashboard.liquid b/src/admin-theme/admin-dashboard.liquid index baed2f7..4d8ac30 100644 --- a/src/admin-theme/admin-dashboard.liquid +++ b/src/admin-theme/admin-dashboard.liquid @@ -2,19 +2,7 @@
    Themes - - Upload a New Theme - -
    - {% include_template "_theme-list-columns" %} -
    -
    Theme
    -
    Slug
    -
    Templates
    -
    -
    -
    - {{ theme_list }} +
    {%- assign cache_base_url = "admin/cache/" -%} diff --git a/src/admin-theme/permalinks.liquid b/src/admin-theme/permalinks.liquid deleted file mode 100644 index 2d4a168..0000000 --- a/src/admin-theme/permalinks.liquid +++ /dev/null @@ -1,59 +0,0 @@ -

    {{ page_title }}

    -
    - {%- assign base_url = "admin/" | append: model.entity | append: "/" -%} -
    - - -
    -
    -
    -

    - {{ model.current_title }}
    - - {{ model.current_permalink }}
    - - « Back to Edit {{ model.entity | capitalize }} - -
    -

    -
    -
    -
    - -
    -
    -
    -
    - -
    -
    -
    -
    - -
    -
    -
    -
    -
    diff --git a/src/admin-theme/revisions.liquid b/src/admin-theme/revisions.liquid deleted file mode 100644 index 4a5ab75..0000000 --- a/src/admin-theme/revisions.liquid +++ /dev/null @@ -1,65 +0,0 @@ -

    {{ page_title }}

    -
    -
    - - -
    -
    -
    -

    - {{ model.current_title }}
    - - - « Back to Edit {{ model.entity | capitalize }} - - -

    -
    - {%- assign revision_count = model.revisions | size -%} - {%- assign rev_url_base = "admin/" | append: model.entity | append: "/" | append: model.id | append: "/revision" -%} - {%- if revision_count > 1 %} -
    -
    - -
    -
    - {%- endif %} -
    Revision
    - {% for rev in model.revisions %} - {%- assign as_of_string = rev.as_of | date: "o" -%} - {%- assign as_of_id = "rev_" | append: as_of_string | replace: "\.", "_" | replace: ":", "-" -%} -
    -
    - {{ rev.as_of_local | date: "MMMM d, yyyy" }} at {{ rev.as_of_local | date: "h:mmtt" | downcase }} - {{ rev.format }} - {%- if forloop.first %} - Current Revision - {%- endif %}
    - {% unless forloop.first %} - {%- assign rev_url_prefix = rev_url_base | append: "/" | append: as_of_string -%} - {%- assign rev_restore = rev_url_prefix | append: "/restore" | relative_link -%} - {%- assign rev_delete = rev_url_prefix | append: "/delete" | relative_link -%} - - - Preview - - - Restore as Current - - - Delete - - - {% endunless %} -
    - {% unless forloop.first %}
    {% endunless %} -
    - {% endfor %} -
    -
    -
    diff --git a/src/admin-theme/settings.liquid b/src/admin-theme/settings.liquid index 4f8e2d8..45d21dc 100644 --- a/src/admin-theme/settings.liquid +++ b/src/admin-theme/settings.liquid @@ -109,19 +109,6 @@
    Users - {% include_template "_user-list-columns" %} - - Add a New User - -
    -
    -
    User; Full Name / E-mail; Last Log On
    - -
    Created
    -
    Last Log On
    -
    -
    diff --git a/src/admin-theme/theme-list-body.liquid b/src/admin-theme/theme-list-body.liquid deleted file mode 100644 index 13eb143..0000000 --- a/src/admin-theme/theme-list-body.liquid +++ /dev/null @@ -1,33 +0,0 @@ -
    - - {% include_template "_theme-list-columns" %} - {% for theme in themes -%} -
    -
    - {{ theme.name }} - {%- if theme.is_in_use %} - IN USE - {%- endif %} - {%- unless theme.is_on_disk %} - NOT ON DISK - {%- endunless %}
    - - v{{ theme.version }} - {% unless theme.is_in_use or theme.id == "default" %} - - {%- assign theme_del_link = "admin/theme/" | append: theme.id | append: "/delete" | relative_link -%} - - Delete - - {% endunless %} - -
    Slug: {{ theme.id }} • {{ theme.template_count }} Templates -
    -
    -
    -
    {{ theme.id }}
    -
    {{ theme.template_count }}
    -
    - {%- endfor %} -
    diff --git a/src/admin-theme/theme-upload.liquid b/src/admin-theme/theme-upload.liquid deleted file mode 100644 index 73f31e8..0000000 --- a/src/admin-theme/theme-upload.liquid +++ /dev/null @@ -1,30 +0,0 @@ -
    -
    {{ page_title }}
    -
    - -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    -
    - - -
    -
    -
    -
    -- 2.45.1 From e9bd3b28025ec69a3dae46373f3863d1cfef3de0 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Wed, 13 Mar 2024 18:17:14 -0400 Subject: [PATCH 095/123] Migrate web log settings page to GVE - Add field functions to tighten up forms --- src/MyWebLog.Tests/Domain/ViewModelsTests.fs | 10 - src/MyWebLog/Handlers/Admin.fs | 40 +--- src/MyWebLog/Views/Admin.fs | 217 +++++++++++++++-- src/MyWebLog/Views/Helpers.fs | 53 +++++ src/MyWebLog/Views/Post.fs | 71 ++---- src/MyWebLog/Views/User.fs | 101 ++------ src/admin-theme/settings.liquid | 236 ------------------- 7 files changed, 287 insertions(+), 441 deletions(-) delete mode 100644 src/admin-theme/settings.liquid diff --git a/src/MyWebLog.Tests/Domain/ViewModelsTests.fs b/src/MyWebLog.Tests/Domain/ViewModelsTests.fs index a4bb736..e55e488 100644 --- a/src/MyWebLog.Tests/Domain/ViewModelsTests.fs +++ b/src/MyWebLog.Tests/Domain/ViewModelsTests.fs @@ -1158,16 +1158,11 @@ let manageRevisionsModelTests = testList "ManageRevisionsModel" [ { AsOf = Noda.epoch + Duration.FromDays 20; Text = Html "

    huh

    " } ] let model = ManageRevisionsModel.FromPage - { WebLog.Empty with TimeZone = "Etc/GMT+1" } { Page.Empty with Id = PageId "revs"; Title = "A Revised Page"; Revisions = revisions } Expect.equal model.Id "revs" "Id not filled properly" Expect.equal model.Entity "page" "Entity not filled properly" Expect.equal model.CurrentTitle "A Revised Page" "CurrentTitle not filled properly" Expect.equal model.Revisions.Length 2 "There should be two revisions" - Expect.equal - model.Revisions[0].AsOfLocal - ((revisions[0].AsOf - Duration.FromHours 1).ToDateTimeUtc()) - "AsOfLocal not filled properly" } test "FromPost succeeds" { let revisions = @@ -1175,16 +1170,11 @@ let manageRevisionsModelTests = testList "ManageRevisionsModel" [ { AsOf = Noda.epoch + Duration.FromDays 12; Text = Html "

    original

    " } ] let model = ManageRevisionsModel.FromPost - { WebLog.Empty with TimeZone = "Etc/GMT-3" } { Post.Empty with Id = PostId "altered"; Title = "Round Two"; Revisions = revisions } Expect.equal model.Id "altered" "Id not filled properly" Expect.equal model.Entity "post" "Entity not filled properly" Expect.equal model.CurrentTitle "Round Two" "CurrentTitle not filled properly" Expect.equal model.Revisions.Length 2 "There should be two revisions" - Expect.equal - model.Revisions[0].AsOfLocal - ((revisions[0].AsOf + Duration.FromHours 3).ToDateTimeUtc()) - "AsOfLocal not filled properly" } ] diff --git a/src/MyWebLog/Handlers/Admin.fs b/src/MyWebLog/Handlers/Admin.fs index d00239d..df5c257 100644 --- a/src/MyWebLog/Handlers/Admin.fs +++ b/src/MyWebLog/Handlers/Admin.fs @@ -483,42 +483,24 @@ module Theme = /// ~~~ WEB LOG SETTINGS ~~~ module WebLog = - open System.Collections.Generic open System.IO // GET /admin/settings let settings : HttpHandler = fun next ctx -> task { - let data = ctx.Data + let data = ctx.Data let! allPages = data.Page.All ctx.WebLog.Id + let pages = + allPages + |> List.sortBy _.Title.ToLower() + |> List.append [ { Page.Empty with Id = PageId "posts"; Title = "- First Page of Posts -" } ] let! themes = data.Theme.All() + let uploads = [ Database; Disk ] + let feeds = ctx.WebLog.Rss.CustomFeeds |> List.map (DisplayCustomFeed.FromFeed (CategoryCache.get ctx)) return! - hashForPage "Web Log Settings" - |> withAntiCsrf ctx - |> addToHash ViewContext.Model (SettingsModel.FromWebLog ctx.WebLog) - |> addToHash "pages" ( - seq { - KeyValuePair.Create("posts", "- First Page of Posts -") - yield! allPages - |> List.sortBy _.Title.ToLower() - |> List.map (fun p -> KeyValuePair.Create(string p.Id, p.Title)) - } - |> Array.ofSeq) - |> addToHash "themes" ( - themes - |> Seq.ofList - |> Seq.map (fun it -> - KeyValuePair.Create(string it.Id, $"{it.Name} (v{it.Version})")) - |> Array.ofSeq) - |> addToHash "upload_values" [| - KeyValuePair.Create(string Database, "Database") - KeyValuePair.Create(string Disk, "Disk") - |] - |> addToHash "rss_model" (EditRssModel.FromRssOptions ctx.WebLog.Rss) - |> addToHash "custom_feeds" ( - ctx.WebLog.Rss.CustomFeeds - |> List.map (DisplayCustomFeed.FromFeed (CategoryCache.get ctx)) - |> Array.ofList) - |> adminView "settings" next ctx + Views.Admin.webLogSettings + (SettingsModel.FromWebLog ctx.WebLog) themes pages uploads (EditRssModel.FromRssOptions ctx.WebLog.Rss) + feeds + |> adminPage "Web Log Settings" true next ctx } // POST /admin/settings diff --git a/src/MyWebLog/Views/Admin.fs b/src/MyWebLog/Views/Admin.fs index f773b46..8568499 100644 --- a/src/MyWebLog/Views/Admin.fs +++ b/src/MyWebLog/Views/Admin.fs @@ -90,25 +90,17 @@ let redirectEdit (model: EditRedirectRuleModel) app = [ input [ _type "hidden"; _name "RuleId"; _value (string model.RuleId) ] div [ _class "row" ] [ div [ _class "col-12 col-lg-5 mb-3" ] [ - div [ _class "form-floating" ] [ - input [ _type "text"; _name "From"; _id "from"; _class "form-control" - _placeholder "From local URL/pattern"; _autofocus; _required; _value model.From ] - label [ _for "from" ] [ raw "From" ] + textField [ _autofocus; _required ] (nameof model.From) "From" model.From [ + span [ _class "form-text" ] [ raw "From local URL/pattern" ] ] ] div [ _class "col-12 col-lg-5 mb-3" ] [ - div [ _class "form-floating" ] [ - input [ _type "text"; _name "To"; _id "to"; _class "form-control"; _placeholder "To URL/pattern" - _required; _value model.To ] - label [ _for "to" ] [ raw "To" ] + textField [ _required ] (nameof model.To) "To" model.To [ + span [ _class "form-text" ] [ raw "To URL/pattern" ] ] ] div [ _class "col-12 col-lg-2 mb-3" ] [ - div [ _class "form-check form-switch" ] [ - input [ _type "checkbox"; _name "IsRegex"; _id "isRegex"; _class "form-check-input"; _value "true" - if model.IsRegex then _checked ] - label [ _for "isRegex" ] [ raw "Use RegEx" ] - ] + checkboxSwitch [] (nameof model.IsRegex) "Use RegEx" model.IsRegex [] ] ] if model.RuleId < 0 then @@ -126,7 +118,7 @@ let redirectEdit (model: EditRedirectRuleModel) app = [ ] div [ _class "row mb-3" ] [ div [ _class "col text-center" ] [ - button [ _type "submit"; _class "btn btn-sm btn-primary" ] [ raw "Save Changes" ] + saveButton; raw "   " a [ _href (relUrl app "admin/settings/redirect-rules"); _class "btn btn-sm btn-secondary ms-3" ] [ raw "Cancel" ] @@ -223,23 +215,15 @@ let tagMapEdit (model: EditTagMapModel) app = [ input [ _type "hidden"; _name "Id"; _value model.Id ] div [ _class "row mb-3" ] [ div [ _class "col-6 col-lg-4 offset-lg-2" ] [ - div [ _class "form-floating" ] [ - input [ _type "text"; _name "Tag"; _id "tag"; _class "form-control"; _placeholder "Tag"; _autofocus - _required; _value model.Tag ] - label [ _for "tag" ] [ raw "Tag" ] - ] + textField [ _autofocus; _required ] (nameof model.Tag) "Tag" model.Tag [] ] div [ _class "col-6 col-lg-4" ] [ - div [ _class "form-floating" ] [ - input [ _type "text"; _name "UrlValue"; _id "urlValue"; _class "form-control" - _placeholder "URL Value"; _required; _value model.UrlValue ] - label [ _for "urlValue" ] [ raw "URL Value" ] - ] + textField [ _required ] (nameof model.UrlValue) "URL Value" model.UrlValue [] ] ] div [ _class "row mb-3" ] [ div [ _class "col text-center" ] [ - button [ _type "submit"; _class "btn btn-sm btn-primary" ] [ raw "Save Changes" ]; raw "   " + saveButton; raw "   " a [ _href (relUrl app "admin/settings/tag-mappings"); _class "btn btn-sm btn-secondary ms-3" ] [ raw "Cancel" ] @@ -380,4 +364,185 @@ let themeUpload app = ] ] |> List.singleton - \ No newline at end of file + + +/// Web log settings page +let webLogSettings + (model: SettingsModel) (themes: Theme list) (pages: Page list) (uploads: UploadDestination list) + (rss: EditRssModel) (feeds: DisplayCustomFeed list) app = [ + h2 [ _class "my-3" ] [ txt app.WebLog.Name; raw " Settings" ] + article [] [ + p [ _class "text-muted" ] [ + raw "Go to: "; a [ _href "#users" ] [ raw "Users" ]; raw " • " + a [ _href "#rss-settings" ] [ raw "RSS Settings" ]; raw " • " + a [ _href "#tag-mappings" ] [ raw "Tag Mappings" ]; raw " • " + a [ _href (relUrl app "admin/settings/redirect-rules") ] [ raw "Redirect Rules" ] + ] + fieldset [ _class "container mb-3" ] [ + legend [] [ raw "Web Log Settings" ] + form [ _action (relUrl app "admin/settings"); _method "post" ] [ + antiCsrf app + div [ _class "container g-0" ] [ + div [ _class "row" ] [ + div [ _class "col-12 col-md-6 col-xl-4 pb-3" ] [ + textField [ _required; _autofocus ] (nameof model.Name) "Name" model.Name [] + ] + div [ _class "col-12 col-md-6 col-xl-4 pb-3" ] [ + textField [ _required ] (nameof model.Slug) "Slug" model.Slug [ + span [ _class "form-text" ] [ + span [ _class "badge rounded-pill bg-warning text-dark" ] [ raw "WARNING" ] + raw " changing this value may break links (" + a [ _href "https://bitbadger.solutions/open-source/myweblog/configuring.html#blog-settings" + _target "_blank" ] [ + raw "more" + ]; raw ")" + ] + ] + ] + div [ _class "col-12 col-md-6 col-xl-4 pb-3" ] [ + textField [] (nameof model.Subtitle) "Subtitle" model.Subtitle [] + ] + div [ _class "col-12 col-md-6 col-xl-4 offset-xl-1 pb-3" ] [ + selectField [ _required ] (nameof model.ThemeId) "Theme" model.ThemeId themes + (fun t -> string t.Id) (fun t -> $"{t.Name} (v{t.Version})") [] + ] + div [ _class "col-12 col-md-6 offset-md-1 col-xl-4 offset-xl-0 pb-3" ] [ + selectField [ _required ] (nameof model.DefaultPage) "Default Page" model.DefaultPage pages + (fun p -> string p.Id) (_.Title) [] + ] + div [ _class "col-12 col-md-4 col-xl-2 pb-3" ] [ + numberField [ _required; _min "0"; _max "50" ] (nameof model.PostsPerPage) "Posts per Page" + model.PostsPerPage [] + ] + ] + div [ _class "row" ] [ + div [ _class "col-12 col-md-4 col-xl-3 offset-xl-2 pb-3" ] [ + textField [ _required ] (nameof model.TimeZone) "Time Zone" model.TimeZone [] + ] + div [ _class "col-12 col-md-4 col-xl-2" ] [ + checkboxSwitch [] (nameof model.AutoHtmx) "Auto-Load htmx" model.AutoHtmx [] + span [ _class "form-text fst-italic" ] [ + a [ _href "https://htmx.org"; _target "_blank"; _rel "noopener" ] [ + raw "What is this?" + ] + ] + ] + div [ _class "col-12 col-md-4 col-xl-3 pb-3" ] [ + selectField [] (nameof model.Uploads) "Default Upload Destination" model.Uploads uploads + string string [] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col text-center" ] [ + button [ _type "submit"; _class "btn btn-primary" ] [ raw "Save Changes" ] + ] + ] + ] + ] + ] + fieldset [ _id "users"; _class "container mb-3 pb-0" ] [ + legend [] [ raw "Users" ] + span [ _hxGet (relUrl app "admin/settings/users"); _hxTrigger HxTrigger.Load; _hxSwap HxSwap.OuterHtml ] [] + ] + fieldset [ _id "rss-settings"; _class "container mb-3 pb-0" ] [ + legend [] [ raw "RSS Settings" ] + form [ _action (relUrl app "admin/settings/rss"); _method "post"; _class "container g-0" ] [ + antiCsrf app + div [ _class "row pb-3" ] [ + div [ _class "col col-xl-8 offset-xl-2" ] [ + fieldset [ _class "d-flex justify-content-evenly flex-row" ] [ + legend [] [ raw "Feeds Enabled" ] + checkboxSwitch [] (nameof rss.IsFeedEnabled) "All Posts" rss.IsFeedEnabled [] + checkboxSwitch [] (nameof rss.IsCategoryEnabled) "Posts by Category" rss.IsCategoryEnabled + [] + checkboxSwitch [] (nameof rss.IsTagEnabled) "Posts by Tag" rss.IsTagEnabled [] + ] + ] + ] + div [ _class "row" ] [ + div [ _class "col-12 col-sm-6 col-md-3 col-xl-2 offset-xl-2 pb-3" ] [ + textField [] (nameof rss.FeedName) "Feed File Name" rss.FeedName [ + span [ _class "form-text" ] [ raw "Default is "; code [] [ raw "feed.xml" ] ] + ] + ] + div [ _class "col-12 col-sm-6 col-md-4 col-xl-2 pb-3" ] [ + numberField [ _required; _min "0" ] (nameof rss.ItemsInFeed) "Items in Feed" rss.ItemsInFeed [ + span [ _class "form-text" ] [ + raw "Set to “0” to use “Posts per Page” setting (" + raw (string app.WebLog.PostsPerPage); raw ")" + ] + ] + ] + div [ _class "col-12 col-md-5 col-xl-4 pb-3" ] [ + textField [] (nameof rss.Copyright) "Copyright String" rss.Copyright [ + span [ _class "form-text" ] [ + raw "Can be a " + a [ _href "https://creativecommons.org/share-your-work/"; _target "_blank" + _rel "noopener" ] [ + raw "Creative Commons license string" + ] + ] + ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col text-center" ] [ + button [ _type "submit"; _class "btn btn-primary" ] [ raw "Save Changes" ] + ] + ] + ] + fieldset [ _class "container mb-3 pb-0" ] [ + legend [] [ raw "Custom Feeds" ] + a [ _class "btn btn-sm btn-secondary"; _href (relUrl app "admin/settings/rss/new/edit") ] [ + raw "Add a New Custom Feed" + ] + if feeds.Length = 0 then + p [ _class "text-muted fst-italic text-center" ] [ raw "No custom feeds defined" ] + else + form [ _method "post"; _class "container g-0"; _hxTarget "body" ] [ + antiCsrf app + div [ _class "row mwl-table-heading" ] [ + div [ _class "col-12 col-md-6" ] [ + span [ _class "d-md-none" ] [ raw "Feed" ] + span [ _class "d-none d-md-inline" ] [ raw "Source" ] + ] + div [ _class $"col-12 col-md-6 d-none d-md-inline-block" ] [ raw "Relative Path" ] + ] + for feed in feeds do + div [ _class "row mwl-table-detail" ] [ + div [ _class "col-12 col-md-6" ] [ + txt feed.Source + if feed.IsPodcast then + raw "   "; span [ _class "badge bg-primary" ] [ raw "PODCAST" ] + br [] + small [] [ + let feedUrl = relUrl app $"admin/settings/rss/{feed.Id}" + a [ _href (relUrl app feed.Path); _target "_blank" ] [ raw "View Feed" ] + span [ _class "text-muted" ] [ raw " • " ] + a [ _href $"{feedUrl}/edit" ] [ raw "Edit" ] + span [ _class "text-muted" ] [ raw " • " ] + a [ _href feedUrl; _hxDelete feedUrl; _class "text-danger" + _hxConfirm $"Are you sure you want to delete the custom RSS feed based on {feed.Source}? This action cannot be undone." ] [ + raw "Delete" + ] + ] + ] + div [ _class "col-12 col-md-6" ] [ + small [ _class "d-md-none" ] [ raw "Served at "; txt feed.Path ] + span [ _class "d-none d-md-inline" ] [ txt feed.Path ] + ] + ] + ] + ] + ] + fieldset [ _id "tag-mappings"; _class "container mb-3 pb-0" ] [ + legend [] [ raw "Tag Mappings" ] + a [ _href (relUrl app "admin/settings/tag-mapping/new/edit"); _class "btn btn-primary btn-sm mb-3" + _hxTarget "#tag_new" ] [ + raw "Add a New Tag Mapping" + ] + span [ _hxGet (relUrl app "admin/settings/tag-mappings"); _hxTrigger HxTrigger.Load + _hxSwap HxSwap.OuterHtml ] [] + ] + ] +] diff --git a/src/MyWebLog/Views/Helpers.fs b/src/MyWebLog/Views/Helpers.fs index ab28e95..5d4dfee 100644 --- a/src/MyWebLog/Views/Helpers.fs +++ b/src/MyWebLog/Views/Helpers.fs @@ -102,6 +102,59 @@ let shortTime app (instant: Instant) = let yesOrNo value = raw (if value then "Yes" else "No") +/// Create a text input field +let inputField fieldType attrs name labelText value extra = + div [ _class "form-floating" ] [ + [ _type fieldType; _name name; _id name; _class "form-control"; _placeholder labelText; _value value ] + |> List.append attrs + |> input + label [ _for name ] [ raw labelText ] + yield! extra + ] + +/// Create a text input field +let textField attrs name labelText value extra = + inputField "text" attrs name labelText value extra + +/// Create a number input field +let numberField attrs name labelText (value: int) extra = + inputField "number" attrs name labelText (string value) extra + +/// Create an e-mail input field +let emailField attrs name labelText value extra = + inputField "email" attrs name labelText value extra + +/// Create a password input field +let passwordField attrs name labelText value extra = + inputField "password" attrs name labelText value extra + +/// Create a select (dropdown) field +let selectField<'T, 'a> + attrs name labelText value (values: 'T list) (idFunc: 'T -> 'a) (displayFunc: 'T -> string) extra = + div [ _class "form-floating" ] [ + select ([ _name name; _id name; _class "form-control" ] |> List.append attrs) [ + for item in values do + let itemId = string (idFunc item) + option [ _value itemId; if value = itemId then _selected ] [ txt (displayFunc item) ] + ] + label [ _for name ] [ raw labelText ] + yield! extra + ] + +/// Create a checkbox input styled as a switch +let checkboxSwitch attrs name labelText (value: bool) extra = + div [ _class "form-check form-switch" ] [ + [ _type "checkbox"; _name name; _id name; _class "form-check-input"; _value "true"; if value then _checked ] + |> List.append attrs + |> input + label [ _for name; _class "form-check-label" ] [ raw labelText ] + yield! extra + ] + +/// A standard save button +let saveButton = + button [ _type "submit"; _class "btn btn-sm btn-primary" ] [ raw "Save Changes" ] + /// Functions for generating content in varying layouts module Layout = diff --git a/src/MyWebLog/Views/Post.fs b/src/MyWebLog/Views/Post.fs index d3e5660..36339f9 100644 --- a/src/MyWebLog/Views/Post.fs +++ b/src/MyWebLog/Views/Post.fs @@ -24,83 +24,47 @@ let chapterEdit (model: EditChapterModel) app = [ input [ _type "hidden"; _name "Index"; _value (string model.Index) ] div [ _class "row" ] [ div [ _class "col-6 col-lg-3 mb-3" ] [ - div [ _class "form-floating" ] [ - input [ _type "text"; _id "start_time"; _name "StartTime"; _class "form-control"; _required - _autofocus; _placeholder "Start Time" - if model.Index >= 0 then _value model.StartTime ] - label [ _for "start_time" ] [ raw "Start Time" ] - ] + textField [ _required; _autofocus ] (nameof model.StartTime) "Start Time" + (if model.Index < 0 then "" else model.StartTime) [] ] div [ _class "col-6 col-lg-3 mb-3" ] [ - div [ _class "form-floating" ] [ - input [ _type "text"; _id "end_time"; _name "EndTime"; _class "form-control"; _value model.EndTime - _placeholder "End Time" ] - label [ _for "end_time" ] [ raw "End Time" ] + textField [] (nameof model.EndTime) "End Time" model.EndTime [ span [ _class "form-text" ] [ raw "Optional; ends when next starts" ] ] ] div [ _class "col-12 col-lg-6 mb-3" ] [ - div [ _class "form-floating" ] [ - input [ _type "text"; _id "title"; _name "Title"; _class "form-control"; _value model.Title - _placeholder "Title" ] - label [ _for "title" ] [ raw "Chapter Title" ] + textField [] (nameof model.Title) "Chapter Title" model.Title [ span [ _class "form-text" ] [ raw "Optional" ] ] ] div [ _class "col-12 col-lg-6 col-xl-5 mb-3" ] [ - div [ _class "form-floating" ] [ - input [ _type "text"; _id "image_url"; _name "ImageUrl"; _class "form-control" - _value model.ImageUrl; _placeholder "Image URL" ] - label [ _for "image_url" ] [ raw "Image URL" ] + textField [] (nameof model.ImageUrl) "Image URL" model.ImageUrl [ span [ _class "form-text" ] [ raw "Optional; a separate image to display while this chapter is playing" ] ] ] div [ _class "col-12 col-lg-6 col-xl-5 mb-3" ] [ - div [ _class "form-floating" ] [ - input [ _type "text"; _id "url"; _name "Url"; _class "form-control"; _value model.Url - _placeholder "URL" ] - label [ _for "url" ] [ raw "URL" ] - span [ _class "form-text" ] [ - raw "Optional; informational link for this chapter" - ] + textField [] (nameof model.Url) "URL" model.Url [ + span [ _class "form-text" ] [ raw "Optional; informational link for this chapter" ] ] ] div [ _class "col-12 col-lg-6 offset-lg-3 col-xl-2 offset-xl-0 mb-3 align-self-end d-flex flex-column" ] [ - div [ _class "form-check form-switch mb-3" ] [ - input [ _type "checkbox"; _id "is_hidden"; _name "IsHidden"; _class "form-check-input" - _value "true" - if model.IsHidden then _checked ] - label [ _for "is_hidden" ] [ raw "Hidden Chapter" ] - ] - span [ _class "form-text" ] [ raw "Not displayed, but may update image and location" ] + checkboxSwitch [] (nameof model.IsHidden) "Hidden Chapter" model.IsHidden [] + span [ _class "mt-2 form-text" ] [ raw "Not displayed, but may update image and location" ] ] ] div [ _class "row" ] [ let hasLoc = model.LocationName <> "" + let attrs = if hasLoc then [] else [ _disabled ] div [ _class "col-12 col-md-4 col-lg-3 offset-lg-1 mb-3 align-self-end" ] [ - div [ _class "form-check form-switch mb-3" ] [ - input [ _type "checkbox"; _id "has_location"; _class "form-check-input"; _value "true" - if hasLoc then _checked - _onclick "Admin.checkChapterLocation()" ] - label [ _for "has_location" ] [ raw "Associate Location" ] - ] + checkboxSwitch [ _onclick "Admin.checkChapterLocation()" ] "has_location" "Associate Location" hasLoc [] ] div [ _class "col-12 col-md-8 col-lg-6 offset-lg-1 mb-3" ] [ - div [ _class "form-floating" ] [ - input [ _type "text"; _id "location_name"; _name "LocationName"; _class "form-control" - _value model.LocationName; _placeholder "Location Name"; _required - if not hasLoc then _disabled ] - label [ _for "location_name" ] [ raw "Name" ] - ] + textField (_required :: attrs) (nameof model.LocationName) "Name" model.LocationName [] ] div [ _class "col-6 col-lg-4 offset-lg-2 mb-3" ] [ - div [ _class "form-floating" ] [ - input [ _type "text"; _id "location_geo"; _name "LocationGeo"; _class "form-control" - _value model.LocationGeo; _placeholder "Location Geo URL"; _required - if not hasLoc then _disabled ] - label [ _for "location_geo" ] [ raw "Geo URL" ] + textField (_required :: attrs) (nameof model.LocationGeo) "Geo URL" model.LocationGeo [ em [ _class "form-text" ] [ a [ _href "https://github.com/Podcastindex-org/podcast-namespace/blob/main/location/location.md#geo-recommended" _target "_blank"; _rel "noopener" ] [ @@ -110,11 +74,7 @@ let chapterEdit (model: EditChapterModel) app = [ ] ] div [ _class "col-6 col-lg-4 mb-3" ] [ - div [ _class "form-floating" ] [ - input [ _type "text"; _id "location_osm"; _name "LocationOsm"; _class "form-control" - _value model.LocationOsm; _placeholder "Location OSM Query" - if not hasLoc then _disabled ] - label [ _for "location_osm" ] [ raw "OpenStreetMap ID" ] + textField attrs (nameof model.LocationOsm) "OpenStreetMap ID" model.LocationOsm [ em [ _class "form-text" ] [ raw "Optional; " a [ _href "https://www.openstreetmap.org/"; _target "_blank"; _rel "noopener" ] [ raw "get ID" ] @@ -138,8 +98,7 @@ let chapterEdit (model: EditChapterModel) app = [ ] else input [ _type "hidden"; _name "AddAnother"; _value "false" ] - button [ _type "submit"; _class "btn btn-primary" ] [ raw "Save" ] - raw "   " + saveButton; raw "   " a [ _href cancelLink; _hxGet cancelLink; _class "btn btn-secondary"; _hxTarget "body" ] [ raw "Cancel" ] ] ] diff --git a/src/MyWebLog/Views/User.fs b/src/MyWebLog/Views/User.fs index 98c9dc2..5db2f57 100644 --- a/src/MyWebLog/Views/User.fs +++ b/src/MyWebLog/Views/User.fs @@ -30,41 +30,21 @@ let edit (model: EditUserModel) app = ] ] div [ _class "col-12 col-md-7 col-lg-4 col-xxl-3 mb-3" ] [ - div [ _class "form-floating" ] [ - input [ _type "email"; _name "Email"; _id "email"; _class "form-control"; _placeholder "E-mail" - _required; _value model.Email ] - label [ _for "email" ] [ raw "E-mail Address" ] - ] + emailField [ _required ] (nameof model.Email) "E-mail Address" model.Email [] ] div [ _class "col-12 col-lg-5 mb-3" ] [ - div [ _class "form-floating" ] [ - input [ _type "text"; _name "Url"; _id "url"; _class "form-control"; _placeholder "URL" - _value model.Url ] - label [ _for "url" ] [ raw "User’s Personal URL" ] - ] + textField [] (nameof model.Url) "User’s Personal URL" model.Url [] ] ] div [ _class "row mb-3" ] [ div [ _class "col-12 col-md-6 col-lg-4 col-xl-3 offset-xl-1 pb-3" ] [ - div [ _class "form-floating" ] [ - input [ _type "text"; _name "FirstName"; _id "firstName"; _class "form-control" - _placeholder "First"; _required; _value model.FirstName ] - label [ _for "firstName" ] [ raw "First Name" ] - ] + textField [ _required ] (nameof model.FirstName) "First Name" model.FirstName [] ] div [ _class "col-12 col-md-6 col-lg-4 col-xl-3 pb-3" ] [ - div [ _class "form-floating" ] [ - input [ _type "text"; _name "LastName"; _id "lastName"; _class "form-control" - _placeholder "Last"; _required; _value model.LastName ] - label [ _for "lastName" ] [ raw "Last Name" ] - ] + textField [ _required ] (nameof model.LastName) "Last Name" model.LastName [] ] div [ _class "col-12 col-md-6 offset-md-3 col-lg-4 offset-lg-0 col-xl-3 offset-xl-1 pb-3" ] [ - div [ _class "form-floating " ] [ - input [ _type "text"; _name "PreferredName"; _id "preferredName"; _class "form-control" - _placeholder "Preferred"; _required; _value model.PreferredName ] - label [ _for "preferredName" ] [ raw "Preferred Name" ] - ] + textField [ _required ] (nameof model.PreferredName) "Preferred Name" model.PreferredName [] ] ] div [ _class "row mb-3" ] [ @@ -83,28 +63,12 @@ let edit (model: EditUserModel) app = ] ] div [ _class "row" ] [ + let attrs, newLbl = if model.IsNew then [ _required ], "" else [], "New " div [ _class "col-12 col-md-6 pb-3" ] [ - div [ _class "form-floating" ] [ - input [ _type "password"; _name "Password"; _id "password"; _class "form-control" - _placeholder "Password" - if model.IsNew then _required ] - label [ _for "password" ] [ - if not model.IsNew then raw "New " - raw "Password" - ] - ] + passwordField attrs (nameof model.Password) $"{newLbl}Password" "" [] ] div [ _class "col-12 col-md-6 pb-3" ] [ - div [ _class "form-floating" ] [ - input [ _type "password"; _name "PasswordConfirm"; _id "passwordConfirm" - _class "form-control"; _placeholder "Confirm" - if model.IsNew then _required ] - label [ _for "passwordConfirm" ] [ - raw "Confirm" - if not model.IsNew then raw " New" - raw " Password" - ] - ] + passwordField attrs (nameof model.PasswordConfirm) $"Confirm {newLbl}Password" "" [] ] ] ] @@ -112,7 +76,7 @@ let edit (model: EditUserModel) app = ] div [ _class "row mb-3" ] [ div [ _class "col text-center" ] [ - button [ _type "submit"; _class "btn btn-sm btn-primary" ] [ raw "Save Changes" ]; raw "   " + saveButton; raw "   " if model.IsNew then button [ _type "button"; _class "btn btn-sm btn-secondary ms-3" _onclick "document.getElementById('user_new').innerHTML = ''" ] [ @@ -138,17 +102,10 @@ let logOn (model: LogOnModel) (app: AppViewContext) = [ if Option.isSome model.ReturnTo then input [ _type "hidden"; _name "ReturnTo"; _value model.ReturnTo.Value ] div [ _class "row" ] [ div [ _class "col-12 col-md-6 col-lg-4 offset-lg-2 pb-3" ] [ - div [ _class "form-floating" ] [ - input [ _type "email"; _id "email"; _name "EmailAddress"; _class "form-control"; _autofocus - _required ] - label [ _for "email" ] [ rawText "E-mail Address" ] - ] + emailField [ _required; _autofocus ] (nameof model.EmailAddress) "E-mail Address" "" [] ] div [ _class "col-12 col-md-6 col-lg-4 pb-3" ] [ - div [ _class "form-floating" ] [ - input [ _type "password"; _id "password"; _name "Password"; _class "form-control"; _required ] - label [ _for "password" ] [ rawText "Password" ] - ] + passwordField [ _required ] (nameof model.Password) "Password" "" [] ] ] div [ _class "row pb-3" ] [ @@ -263,25 +220,13 @@ let myInfo (model: EditMyInfoModel) (user: WebLogUser) app = [ div [ _class "row" ] [ div [ _class "col" ] [ hr [ _class "mt-0" ] ] ] div [ _class "row mb-3" ] [ div [ _class "col-12 col-md-6 col-lg-4 pb-3" ] [ - div [ _class "form-floating" ] [ - input [ _type "text"; _name "FirstName"; _id "firstName"; _class "form-control"; _autofocus - _required; _placeholder "First"; _value model.FirstName ] - label [ _for "firstName" ] [ raw "First Name" ] - ] + textField [ _required; _autofocus ] (nameof model.FirstName) "First Name" model.FirstName [] ] div [ _class "col-12 col-md-6 col-lg-4 pb-3" ] [ - div [ _class "form-floating" ] [ - input [ _type "text"; _name "LastName"; _id "lastName"; _class "form-control"; _required - _placeholder "Last"; _value model.LastName ] - label [ _for "lastName" ] [ raw "Last Name" ] - ] + textField [ _required ] (nameof model.LastName) "Last Name" model.LastName [] ] div [ _class "col-12 col-md-6 col-lg-4 pb-3" ] [ - div [ _class "form-floating" ] [ - input [ _type "text"; _name "PreferredName"; _id "preferredName"; _class "form-control" - _required; _placeholder "Preferred"; _value model.PreferredName ] - label [ _for "preferredName" ] [ raw "Preferred Name" ] - ] + textField [ _required ] (nameof model.PreferredName) "Preferred Name" model.PreferredName [] ] ] div [ _class "row mb-3" ] [ @@ -297,28 +242,16 @@ let myInfo (model: EditMyInfoModel) (user: WebLogUser) app = [ ] div [ _class "row" ] [ div [ _class "col-12 col-md-6 pb-3" ] [ - div [ _class "form-floating" ] [ - input [ _type "password"; _name "NewPassword"; _id "newPassword" - _class "form-control"; _placeholder "Password" ] - label [ _for "newPassword" ] [ raw "New Password" ] - ] + passwordField [] (nameof model.NewPassword) "New Password" "" [] ] div [ _class "col-12 col-md-6 pb-3" ] [ - div [ _class "form-floating" ] [ - input [ _type "password"; _name "NewPasswordConfirm"; _id "newPasswordConfirm" - _class "form-control"; _placeholder "Confirm" ] - label [ _for "newPasswordConfirm" ] [ raw "Confirm New Password" ] - ] + passwordField [] (nameof model.NewPasswordConfirm) "Confirm New Password" "" [] ] ] ] ] ] - div [ _class "row" ] [ - div [ _class "col text-center mb-3" ] [ - button [ _type "submit"; _class "btn btn-primary" ] [ raw "Save Changes" ] - ] - ] + div [ _class "row" ] [ div [ _class "col text-center mb-3" ] [ saveButton ] ] ] ] ] diff --git a/src/admin-theme/settings.liquid b/src/admin-theme/settings.liquid deleted file mode 100644 index 45d21dc..0000000 --- a/src/admin-theme/settings.liquid +++ /dev/null @@ -1,236 +0,0 @@ -

    {{ web_log.name }} Settings

    -
    -

    - Go to: UsersRSS Settings • - Tag Mappings • - Redirect Rules -

    -
    - Web Log Settings -
    - -
    -
    -
    -
    - - -
    -
    -
    -
    - - - - WARNING changing this value may break - links - (more) - -
    -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    -
    -
    - - -
    -
    -
    -
    - - -
    - - What is this? - -
    -
    -
    - - -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    - Users - -
    -
    - RSS Settings -
    - -
    -
    -
    -
    - 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 feed_count = custom_feeds | size -%} - {%- if feed_count > 0 %} -
    - {%- assign source_col = "col-12 col-md-6" -%} - {%- assign path_col = "col-12 col-md-6" -%} - -
    -
    - FeedSource -
    -
    Relative Path
    -
    - {% for feed in custom_feeds %} -
    -
    - {{ feed.source }} - {%- if feed.is_podcast %}   PODCAST{% endif %}
    - - {%- assign feed_url = "admin/settings/rss/" | append: feed.id -%} - View Feed - - Edit - - {%- assign feed_del_link = feed_url | append: "/delete" | relative_link -%} - - Delete - - -
    -
    - Served at {{ feed.path }} - {{ feed.path }} -
    -
    - {%- endfor %} -
    - {%- else %} -

    No custom feeds defined - {%- endif %} -

    -
    -
    - Tag Mappings - - Add a New Tag Mapping - - -
    -
    -- 2.45.1 From cac7b1d9b536572b7c937032c067a3847e502b3e Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Wed, 13 Mar 2024 21:31:15 -0400 Subject: [PATCH 096/123] Migrate page list template to GVE - Update chapter field names in admin.js --- src/MyWebLog/Handlers/Page.fs | 22 ++++----- src/MyWebLog/Handlers/Routes.fs | 2 +- src/MyWebLog/MyWebLog.fsproj | 1 + src/MyWebLog/Views/Page.fs | 84 ++++++++++++++++++++++++++++++++ src/admin-theme/page-list.liquid | 75 ---------------------------- src/admin-theme/wwwroot/admin.js | 2 +- 6 files changed, 96 insertions(+), 90 deletions(-) create mode 100644 src/MyWebLog/Views/Page.fs delete mode 100644 src/admin-theme/page-list.liquid diff --git a/src/MyWebLog/Handlers/Page.fs b/src/MyWebLog/Handlers/Page.fs index 5cf619b..9563b94 100644 --- a/src/MyWebLog/Handlers/Page.fs +++ b/src/MyWebLog/Handlers/Page.fs @@ -9,19 +9,15 @@ open MyWebLog.ViewModels // GET /admin/pages/page/{pageNbr} let all pageNbr : HttpHandler = requireAccess Author >=> fun next ctx -> task { let! pages = ctx.Data.Page.FindPageOfPages ctx.WebLog.Id pageNbr + let displayPages = + pages + |> Seq.ofList + |> Seq.truncate 25 + |> Seq.map (DisplayPage.FromPageMinimal ctx.WebLog) + |> List.ofSeq return! - hashForPage "Pages" - |> withAntiCsrf ctx - |> addToHash "pages" (pages - |> Seq.ofList - |> Seq.truncate 25 - |> Seq.map (DisplayPage.FromPageMinimal ctx.WebLog) - |> List.ofSeq) - |> addToHash "page_nbr" pageNbr - |> addToHash "prev_page" (if pageNbr = 2 then "" else $"/page/{pageNbr - 1}") - |> addToHash "has_next" (List.length pages > 25) - |> addToHash "next_page" $"/page/{pageNbr + 1}" - |> adminView "page-list" next ctx + Views.Page.pageList displayPages pageNbr (pages.Length > 25) + |> adminPage "Pages" true next ctx } // GET /admin/page/{id}/edit @@ -51,7 +47,7 @@ let edit pgId : HttpHandler = requireAccess Author >=> fun next ctx -> task { | None -> return! Error.notFound next ctx } -// POST /admin/page/{id}/delete +// DELETE /admin/page/{id} let delete pgId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { match! ctx.Data.Page.Delete (PageId pgId) ctx.WebLog.Id with | true -> diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index f0db9ca..87fe4e5 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -175,7 +175,6 @@ let router : HttpHandler = choose [ subRoute "/page" (choose [ route "/save" >=> Page.save route "/permalinks" >=> Page.savePermalinks - routef "/%s/delete" Page.delete routef "/%s/revision/%s/restore" Page.restoreRevision ]) subRoute "/post" (choose [ @@ -211,6 +210,7 @@ let router : HttpHandler = choose [ ] DELETE >=> validateCsrf >=> choose [ subRoute "/page" (choose [ + routef "/%s" Page.delete routef "/%s/revision/%s" Page.deleteRevision routef "/%s/revisions" Page.purgeRevisions ]) diff --git a/src/MyWebLog/MyWebLog.fsproj b/src/MyWebLog/MyWebLog.fsproj index c795441..40b0eb6 100644 --- a/src/MyWebLog/MyWebLog.fsproj +++ b/src/MyWebLog/MyWebLog.fsproj @@ -11,6 +11,7 @@ + diff --git a/src/MyWebLog/Views/Page.fs b/src/MyWebLog/Views/Page.fs new file mode 100644 index 0000000..360858d --- /dev/null +++ b/src/MyWebLog/Views/Page.fs @@ -0,0 +1,84 @@ +module MyWebLog.Views.Page + +open Giraffe.ViewEngine +open Giraffe.ViewEngine.Htmx +open MyWebLog +open MyWebLog.ViewModels + +/// Display a list of pages for this web log +let pageList (pages: DisplayPage list) pageNbr hasNext app = [ + h2 [ _class "my-3" ] [ raw app.PageTitle ] + article [] [ + a [ _href (relUrl app "admin/page/new/edit"); _class "btn btn-primary btn-sm mb-3" ] [ raw "Create a New Page" ] + if pages.Length = 0 then + p [ _class "text-muted fst-italic text-center" ] [ raw "This web log has no pages" ] + else + let titleCol = "col-12 col-md-5" + let linkCol = "col-12 col-md-5" + let upd8Col = "col-12 col-md-2" + form [ _method "post"; _class "container mb-3"; _hxTarget "body" ] [ + antiCsrf app + div [ _class "row mwl-table-heading" ] [ + div [ _class titleCol ] [ + span [ _class "d-none d-md-inline" ] [ raw "Title" ]; span [ _class "d-md-none" ] [ raw "Page" ] + ] + div [ _class $"{linkCol} d-none d-md-inline-block" ] [ raw "Permalink" ] + div [ _class $"{upd8Col} d-none d-md-inline-block" ] [ raw "Updated" ] + ] + for pg in pages do + let pageLink = if pg.IsDefault then "" else pg.Permalink + div [ _class "row mwl-table-detail" ] [ + div [ _class titleCol ] [ + txt pg.Title + if pg.IsDefault then + raw "   "; span [ _class "badge bg-success" ] [ raw "HOME PAGE" ] + if pg.IsInPageList then + raw "   "; span [ _class "badge bg-primary" ] [ raw "IN PAGE LIST" ] + br [] ; small [] [ + let adminUrl = relUrl app $"admin/page/{pg.Id}" + a [ _href (relUrl app pageLink); _target "_blank" ] [ raw "View Page" ] + if app.IsEditor || (app.IsAuthor && app.UserId.Value = WebLogUserId pg.AuthorId) then + span [ _class "text-muted" ] [ raw " • " ] + a [ _href $"{adminUrl}/edit" ] [ raw "Edit" ] + if app.IsWebLogAdmin then + span [ _class "text-muted" ] [ raw " • " ] + a [ _href adminUrl; _hxDelete adminUrl; _class "text-danger" + _hxConfirm $"Are you sure you want to delete the page “{pg.Title}”? This action cannot be undone." ] [ + raw "Delete" + ] + ] + ] + div [ _class linkCol ] [ + small [ _class "d-md-none" ] [ txt pageLink ] + span [ _class "d-none d-md-inline" ] [ txt pageLink ] + ] + div [ _class upd8Col ] [ + small [ _class "d-md-none text-muted" ] [ + raw "Updated "; txt (pg.UpdatedOn.ToString "MMMM d, yyyy") + ] + span [ _class "d-none d-md-inline" ] [ txt (pg.UpdatedOn.ToString "MMMM d, yyyy") ] + ] + ] + ] + if pageNbr > 1 || hasNext then + div [ _class "d-flex justify-content-evenly mb-3" ] [ + div [] [ + if pageNbr > 1 then + let prevPage = if pageNbr = 2 then "" else $"/page/{pageNbr - 1}" + p [] [ + a [ _class "btn btn-secondary"; _href (relUrl app $"admin/pages{prevPage}") ] [ + raw "« Previous" + ] + ] + ] + div [ _class "text-right" ] [ + if hasNext then + p [] [ + a [ _class "btn btn-secondary"; _href (relUrl app $"admin/pages/page/{pageNbr + 1}") ] [ + raw "Next »" + ] + ] + ] + ] + ] +] diff --git a/src/admin-theme/page-list.liquid b/src/admin-theme/page-list.liquid deleted file mode 100644 index 7682f02..0000000 --- a/src/admin-theme/page-list.liquid +++ /dev/null @@ -1,75 +0,0 @@ -

    {{ page_title }}

    -
    - Create a New Page - {%- assign page_count = pages | size -%} - {% if page_count > 0 %} - {%- 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
    -
    - {% for pg in pages -%} -
    -
    - {{ pg.title }} - {%- if pg.is_default %}   HOME PAGE{% endif -%} - {%- if pg.is_in_page_list %}   IN PAGE LIST {% endif -%}
    - - {%- capture pg_link %}{% unless pg.is_default %}{{ pg.permalink }}{% endunless %}{% endcapture -%} - View Page - {% if is_editor or is_author and user_id == pg.author_id %} - - Edit - {% endif %} - {% if is_web_log_admin %} - - {%- assign pg_del_link = "admin/page/" | append: pg.id | append: "/delete" | relative_link -%} - - Delete - - {% endif %} - -
    - -
    - Updated {{ pg.updated_on | date: "MMMM d, yyyy" }} - {{ pg.updated_on | date: "MMMM d, yyyy" }} -
    -
    - {%- endfor %} -
    - {% if page_nbr > 1 or has_next %} -
    -
    - {% if page_nbr > 1 %} -

    - - « Previous - - {% endif %} -

    -
    - {% if has_next %} -

    - - Next » - - {% endif %} -

    -
    - {% endif %} - {% else %} -

    This web log has no pages

    - {% endif %} -
    diff --git a/src/admin-theme/wwwroot/admin.js b/src/admin-theme/wwwroot/admin.js index 4867539..4727ca9 100644 --- a/src/admin-theme/wwwroot/admin.js +++ b/src/admin-theme/wwwroot/admin.js @@ -310,7 +310,7 @@ this.Admin = { */ checkChapterLocation() { const isDisabled = !document.getElementById("has_location").checked - ;["location_name", "location_geo", "location_osm"].forEach(it => { + ;["LocationName", "LocationGeo", "LocationOsm"].forEach(it => { const elt = document.getElementById(it) elt.disabled = isDisabled if (isDisabled) elt.value = "" -- 2.45.1 From b85cae2241bfff7602f131cb85478b2a68cf448d Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 14 Mar 2024 20:54:58 -0400 Subject: [PATCH 097/123] Tweak to chapter edit template --- src/MyWebLog/Views/Post.fs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/MyWebLog/Views/Post.fs b/src/MyWebLog/Views/Post.fs index 36339f9..fa0de21 100644 --- a/src/MyWebLog/Views/Post.fs +++ b/src/MyWebLog/Views/Post.fs @@ -55,8 +55,7 @@ let chapterEdit (model: EditChapterModel) app = [ ] ] div [ _class "row" ] [ - let hasLoc = model.LocationName <> "" - let attrs = if hasLoc then [] else [ _disabled ] + let hasLoc, attrs = if model.LocationName = "" then false, [ _disabled ] else true, [] div [ _class "col-12 col-md-4 col-lg-3 offset-lg-1 mb-3 align-self-end" ] [ checkboxSwitch [ _onclick "Admin.checkChapterLocation()" ] "has_location" "Associate Location" hasLoc [] ] @@ -91,11 +90,7 @@ let chapterEdit (model: EditChapterModel) app = [ div [ _class "col" ] [ let cancelLink = relUrl app $"admin/post/{model.PostId}/chapters" if model.Index < 0 then - div [ _class "form-check form-switch mb-3" ] [ - input [ _type "checkbox"; _id "add_another"; _name "AddAnother"; _class "form-check-input" - _value "true"; _checked ] - label [ _for "add_another" ] [ raw "Add Another New Chapter" ] - ] + checkboxSwitch [ _checked ] (nameof model.AddAnother) "Add Another New Chapter" true [] else input [ _type "hidden"; _name "AddAnother"; _value "false" ] saveButton; raw "   " -- 2.45.1 From 91046c643eca10c37d90574c907990e229b2ba80 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 14 Mar 2024 22:50:37 -0400 Subject: [PATCH 098/123] Migrate custom feed page to GVE - Allow IDs to be overridden --- src/MyWebLog/Handlers/Feed.fs | 31 +-- src/MyWebLog/Views/Admin.fs | 210 +++++++++++++++++++ src/MyWebLog/Views/Helpers.fs | 14 +- src/admin-theme/custom-feed-edit.liquid | 259 ------------------------ src/admin-theme/wwwroot/admin.js | 6 +- 5 files changed, 240 insertions(+), 280 deletions(-) delete mode 100644 src/admin-theme/custom-feed-edit.liquid diff --git a/src/MyWebLog/Handlers/Feed.fs b/src/MyWebLog/Handlers/Feed.fs index 94dfd75..c1e8cdd 100644 --- a/src/MyWebLog/Handlers/Feed.fs +++ b/src/MyWebLog/Handlers/Feed.fs @@ -431,20 +431,23 @@ let editCustomFeed feedId : HttpHandler = requireAccess WebLogAdmin >=> fun next | _ -> ctx.WebLog.Rss.CustomFeeds |> List.tryFind (fun f -> f.Id = CustomFeedId feedId) match customFeed with | Some f -> - hashForPage $"""{if feedId = "new" then "Add" else "Edit"} Custom RSS Feed""" - |> withAntiCsrf ctx - |> addToHash ViewContext.Model (EditCustomFeedModel.FromFeed f) - |> addToHash "medium_values" [| - KeyValuePair.Create("", "– Unspecified –") - KeyValuePair.Create(string Podcast, "Podcast") - KeyValuePair.Create(string Music, "Music") - KeyValuePair.Create(string Video, "Video") - KeyValuePair.Create(string Film, "Film") - KeyValuePair.Create(string Audiobook, "Audiobook") - KeyValuePair.Create(string Newsletter, "Newsletter") - KeyValuePair.Create(string Blog, "Blog") - |] - |> adminView "custom-feed-edit" next ctx + let ratings = [ + { Name = string Yes; Value = "Yes" } + { Name = string No; Value = "No" } + { Name = string Clean; Value = "Clean" } + ] + let mediums = [ + { Name = ""; Value = "– Unspecified –" } + { Name = string Podcast; Value = "Podcast" } + { Name = string Music; Value = "Music" } + { Name = string Video; Value = "Video" } + { Name = string Film; Value = "Film" } + { Name = string Audiobook; Value = "Audiobook" } + { Name = string Newsletter; Value = "Newsletter" } + { Name = string Blog; Value = "Blog" } + ] + Views.Admin.feedEdit (EditCustomFeedModel.FromFeed f) ratings mediums + |> adminPage $"""{if feedId = "new" then "Add" else "Edit"} Custom RSS Feed""" true next ctx | None -> Error.notFound next ctx // POST /admin/settings/rss/save diff --git a/src/MyWebLog/Views/Admin.fs b/src/MyWebLog/Views/Admin.fs index 8568499..e1e171a 100644 --- a/src/MyWebLog/Views/Admin.fs +++ b/src/MyWebLog/Views/Admin.fs @@ -81,6 +81,216 @@ let dashboard (model: DashboardModel) app = [ ] +/// Custom RSS feed edit form +let feedEdit (model: EditCustomFeedModel) (ratings: MetaItem list) (mediums: MetaItem list) app = [ + h2 [ _class "my-3" ] [ raw app.PageTitle ] + article [] [ + form [ _action (relUrl app "admin/settings/rss/save"); _method "post"; _class "container" ] [ + antiCsrf app + input [ _type "hidden"; _name "Id"; _value model.Id ] + div [ _class "row pb-3" ] [ + div [ _class "col" ] [ + a [ _href (relUrl app "admin/settings#rss-settings") ] [ raw "« Back to Settings" ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col-12 col-lg-6" ] [ + fieldset [ _class "container pb-0" ] [ + legend [] [ raw "Identification" ] + div [ _class "row" ] [ + div [ _class "col" ] [ + textField [ _required ] (nameof model.Path) "Relative Feed Path" model.Path [ + span [ _class "form-text fst-italic" ] [ raw "Appended to "; txt app.WebLog.UrlBase ] + ] + ] + ] + div [ _class "row" ] [ + div [ _class "col py-3 d-flex align-self-center justify-content-center" ] [ + checkboxSwitch [ _onclick "Admin.checkPodcast()"; if model.IsPodcast then _checked ] + (nameof model.IsPodcast) "This Is a Podcast Feed" model.IsPodcast [] + ] + ] + ] + ] + div [ _class "col-12 col-lg-6" ] [ + fieldset [ _class "container pb-0" ] [ + legend [] [ raw "Feed Source" ] + div [ _class "row d-flex align-items-center" ] [ + div [ _class "col-1 d-flex justify-content-end pb-3" ] [ + div [ _class "form-check form-check-inline me-0" ] [ + input [ _type "radio"; _name (nameof model.SourceType); _id "SourceTypeCat" + _class "form-check-input"; _value "category" + if model.SourceType <> "tag" then _checked + _onclick "Admin.customFeedBy('category')" ] + label [ _for "SourceTypeCat"; _class "form-check-label d-none" ] [ raw "Category" ] + ] + ] + div [ _class "col-11 pb-3" ] [ + let cats = + app.Categories + |> Seq.ofArray + |> Seq.map (fun c -> + let parents = + if c.ParentNames.Length = 0 then "" + else + c.ParentNames + |> Array.map (fun it -> $"{it} ⟩ ") + |> String.concat "" + { Name = c.Id; Value = $"{parents} {c.Name}".Trim() }) + |> Seq.append [ { Name = ""; Value = "– Select Category –" } ] + |> List.ofSeq + selectField [ _id "SourceValueCat"; _required + if model.SourceType = "tag" then _disabled ] + (nameof model.SourceValue) "Category" model.SourceValue cats (_.Name) + (_.Value) [] + ] + div [ _class "col-1 d-flex justify-content-end pb-3" ] [ + div [ _class "form-check form-check-inline me-0" ] [ + input [ _type "radio"; _name (nameof model.SourceType); _id "SourceTypeTag" + _class "form-check-input"; _value "tag" + if model.SourceType= "tag" then _checked + _onclick "Admin.customFeedBy('tag')" ] + label [ _for "sourceTypeTag"; _class "form-check-label d-none" ] [ raw "Tag" ] + ] + ] + div [ _class "col-11 pb-3" ] [ + textField [ _id "SourceValueTag"; _required + if model.SourceType <> "tag" then _disabled ] + (nameof model.SourceValue) "Tag" + (if model.SourceType = "tag" then model.SourceValue else "") [] + ] + ] + ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col" ] [ + fieldset [ _class "container"; _id "podcastFields"; if not model.IsPodcast then _disabled ] [ + legend [] [ raw "Podcast Settings" ] + div [ _class "row" ] [ + div [ _class "col-12 col-md-5 col-lg-4 offset-lg-1 pb-3" ] [ + textField [ _required ] (nameof model.Title) "Title" model.Title [] + ] + div [ _class "col-12 col-md-4 col-lg-4 pb-3" ] [ + textField [] (nameof model.Subtitle) "Podcast Subtitle" model.Subtitle [] + ] + div [ _class "col-12 col-md-3 col-lg-2 pb-3" ] [ + numberField [ _required ] (nameof model.ItemsInFeed) "# Episodes" model.ItemsInFeed [] + ] + ] + div [ _class "row" ] [ + div [ _class "col-12 col-md-5 col-lg-4 offset-lg-1 pb-3" ] [ + textField [ _required ] (nameof model.AppleCategory) "iTunes Category" + model.AppleCategory [ + span [ _class "form-text fst-italic" ] [ + a [ _href "https://www.thepodcasthost.com/planning/itunes-podcast-categories/" + _target "_blank"; _rel "noopener" ] [ + raw "iTunes Category / Subcategory List" + ] + ] + ] + ] + div [ _class "col-12 col-md-4 pb-3" ] [ + textField [] (nameof model.AppleSubcategory) "iTunes Subcategory" model.AppleSubcategory + [] + ] + div [ _class "col-12 col-md-3 col-lg-2 pb-3" ] [ + selectField [ _required ] (nameof model.Explicit) "Explicit Rating" model.Explicit + ratings (_.Name) (_.Value) [] + ] + ] + div [ _class "row" ] [ + div [ _class "col-12 col-md-6 col-lg-4 offset-xxl-1 pb-3" ] [ + textField [ _required ] (nameof model.DisplayedAuthor) "Displayed Author" + model.DisplayedAuthor [] + ] + div [ _class "col-12 col-md-6 col-lg-4 pb-3" ] [ + emailField [ _required ] (nameof model.Email) "Author E-mail" model.Email [ + span [ _class "form-text fst-italic" ] [ + raw "For iTunes, must match registered e-mail" + ] + ] + ] + div [ _class "col-12 col-sm-5 col-md-4 col-lg-4 col-xl-3 offset-xl-1 col-xxl-2 offset-xxl-0 pb-3" ] [ + textField [] (nameof model.DefaultMediaType) "Default Media Type" + model.DefaultMediaType [ + span [ _class "form-text fst-italic" ] [ raw "Optional; blank for no default" ] + ] + ] + div [ _class "col-12 col-sm-7 col-md-8 col-lg-10 offset-lg-1 pb-3" ] [ + textField [ _required ] (nameof model.ImageUrl) "Image URL" model.ImageUrl [ + span [ _class "form-text fst-italic"] [ + raw "Relative URL will be appended to "; txt app.WebLog.UrlBase; raw "/" + ] + ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col-12 col-lg-10 offset-lg-1" ] [ + textField [ _required ] (nameof model.Summary) "Summary" model.Summary [ + span [ _class "form-text fst-italic" ] [ raw "Displayed in podcast directories" ] + ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col-12 col-lg-10 offset-lg-1" ] [ + textField [] (nameof model.MediaBaseUrl) "Media Base URL" model.MediaBaseUrl [ + span [ _class "form-text fst-italic" ] [ + raw "Optional; prepended to episode media file if present" + ] + ] + ] + ] + div [ _class "row" ] [ + div [ _class "col-12 col-lg-5 offset-lg-1 pb-3" ] [ + textField [] (nameof model.FundingUrl) "Funding URL" model.FundingUrl [ + span [ _class "form-text fst-italic" ] [ + raw "Optional; URL describing donation options for this podcast, " + raw "relative URL supported" + ] + ] + ] + div [ _class "col-12 col-lg-5 pb-3" ] [ + textField [ _maxlength "128" ] (nameof model.FundingText) "Funding Text" + model.FundingText [ + span [ _class "form-text fst-italic" ] [ raw "Optional; text for the funding link" ] + ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col-8 col-lg-5 offset-lg-1 pb-3" ] [ + textField [] (nameof model.PodcastGuid) "Podcast GUID" model.PodcastGuid [ + span [ _class "form-text fst-italic" ] [ + raw "Optional; v5 UUID uniquely identifying this podcast; " + raw "once entered, do not change this value (" + a [ _href "https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#guid" + _target "_blank"; _rel "noopener" ] [ + raw "documentation" + ]; raw ")" + ] + ] + ] + div [ _class "col-4 col-lg-3 offset-lg-2 pb-3" ] [ + selectField [] (nameof model.Medium) "Medium" model.Medium mediums (_.Name) (_.Value) [ + span [ _class "form-text fst-italic" ] [ + raw "Optional; medium of the podcast content (" + a [ _href "https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#medium" + _target "_blank"; _rel "noopener" ] [ + raw "documentation" + ]; raw ")" + ] + ] + ] + ] + ] + ] + ] + div [ _class "row pb-3" ] [ div [ _class "col text-center" ] [ saveButton ] ] + ] + ] +] + + /// Redirect Rule edit form let redirectEdit (model: EditRedirectRuleModel) app = [ let url = relUrl app $"admin/settings/redirect-rules/{model.RuleId}" diff --git a/src/MyWebLog/Views/Helpers.fs b/src/MyWebLog/Views/Helpers.fs index 5d4dfee..5372127 100644 --- a/src/MyWebLog/Views/Helpers.fs +++ b/src/MyWebLog/Views/Helpers.fs @@ -104,11 +104,17 @@ let yesOrNo value = /// Create a text input field let inputField fieldType attrs name labelText value extra = + let fieldId, newAttrs = + let passedId = attrs |> List.tryFind (fun x -> match x with KeyValue ("id", _) -> true | _ -> false) + match passedId with + | Some (KeyValue (_, idValue)) -> + idValue, attrs |> List.filter (fun x -> match x with KeyValue ("id", _) -> false | _ -> true) + | Some _ | None -> name, attrs div [ _class "form-floating" ] [ - [ _type fieldType; _name name; _id name; _class "form-control"; _placeholder labelText; _value value ] - |> List.append attrs + [ _type fieldType; _name name; _id fieldId; _class "form-control"; _placeholder labelText; _value value ] + |> List.append newAttrs |> input - label [ _for name ] [ raw labelText ] + label [ _for fieldId ] [ raw labelText ] yield! extra ] @@ -135,7 +141,7 @@ let selectField<'T, 'a> select ([ _name name; _id name; _class "form-control" ] |> List.append attrs) [ for item in values do let itemId = string (idFunc item) - option [ _value itemId; if value = itemId then _selected ] [ txt (displayFunc item) ] + option [ _value itemId; if value = itemId then _selected ] [ raw (displayFunc item) ] ] label [ _for name ] [ raw labelText ] yield! extra diff --git a/src/admin-theme/custom-feed-edit.liquid b/src/admin-theme/custom-feed-edit.liquid deleted file mode 100644 index 20d4297..0000000 --- a/src/admin-theme/custom-feed-edit.liquid +++ /dev/null @@ -1,259 +0,0 @@ -

    {{ 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 -
    -
    -
    -
    -
    -
    - - - - Optional; URL describing donation options for this podcast, relative URL supported - -
    -
    -
    -
    - - - Optional; text for the funding link -
    -
    -
    -
    -
    -
    - - - - Optional; v5 UUID uniquely identifying this podcast; once entered, do not change this value - (documentation) - -
    -
    -
    -
    - - - - Optional; medium of the podcast content - (documentation) - -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    diff --git a/src/admin-theme/wwwroot/admin.js b/src/admin-theme/wwwroot/admin.js index 4727ca9..d69e134 100644 --- a/src/admin-theme/wwwroot/admin.js +++ b/src/admin-theme/wwwroot/admin.js @@ -249,7 +249,7 @@ this.Admin = { * Check to enable or disable podcast fields */ checkPodcast() { - document.getElementById("podcastFields").disabled = !document.getElementById("isPodcast").checked + document.getElementById("podcastFields").disabled = !document.getElementById("IsPodcast").checked }, /** @@ -269,8 +269,8 @@ this.Admin = { * @param {string} source The source that was selected */ customFeedBy(source) { - const categoryInput = document.getElementById("sourceValueCat") - const tagInput = document.getElementById("sourceValueTag") + const categoryInput = document.getElementById("SourceValueCat") + const tagInput = document.getElementById("SourceValueTag") if (source === "category") { tagInput.value = "" tagInput.disabled = true -- 2.45.1 From 3998399e1114a30342a7660bd0eeda0eb53c4dc2 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Fri, 15 Mar 2024 17:00:32 -0400 Subject: [PATCH 099/123] Migrate several templates to GVE All that remains is post/page edit --- src/MyWebLog/Handlers/Admin.fs | 82 +- src/MyWebLog/Handlers/Feed.fs | 5 +- src/MyWebLog/Handlers/Routes.fs | 22 +- src/MyWebLog/Handlers/Upload.fs | 30 +- src/MyWebLog/MyWebLog.fsproj | 1 + src/MyWebLog/Views/Admin.fs | 720 ++---------------- src/MyWebLog/Views/Helpers.fs | 9 +- src/MyWebLog/Views/Post.fs | 6 +- src/MyWebLog/Views/WebLog.fs | 875 ++++++++++++++++++++++ src/admin-theme/admin-dashboard.liquid | 92 --- src/admin-theme/category-edit.liquid | 52 -- src/admin-theme/category-list-body.liquid | 57 -- src/admin-theme/category-list.liquid | 8 - src/admin-theme/upload-list.liquid | 75 -- src/admin-theme/upload-new.liquid | 29 - 15 files changed, 1009 insertions(+), 1054 deletions(-) create mode 100644 src/MyWebLog/Views/WebLog.fs delete mode 100644 src/admin-theme/admin-dashboard.liquid delete mode 100644 src/admin-theme/category-edit.liquid delete mode 100644 src/admin-theme/category-list-body.liquid delete mode 100644 src/admin-theme/category-list.liquid delete mode 100644 src/admin-theme/upload-list.liquid delete mode 100644 src/admin-theme/upload-new.liquid diff --git a/src/MyWebLog/Handlers/Admin.fs b/src/MyWebLog/Handlers/Admin.fs index df5c257..2446197 100644 --- a/src/MyWebLog/Handlers/Admin.fs +++ b/src/MyWebLog/Handlers/Admin.fs @@ -27,35 +27,13 @@ module Dashboard = ListedPages = listed Categories = cats TopLevelCategories = topCats } - return! adminPage "Dashboard" false next ctx (Views.Admin.dashboard model) + return! adminPage "Dashboard" false next ctx (Views.WebLog.dashboard model) } // GET /admin/administration let admin : HttpHandler = requireAccess Administrator >=> fun next ctx -> task { - let! themes = ctx.Data.Theme.All() - let cachedTemplates = TemplateCache.allNames () - return! - hashForPage "myWebLog Administration" - |> withAntiCsrf ctx - |> addToHash "cached_themes" ( - themes - |> Seq.ofList - |> Seq.map (fun it -> [| - string it.Id - it.Name - cachedTemplates - |> List.filter _.StartsWith(string it.Id) - |> List.length - |> string - |]) - |> Array.ofSeq) - |> addToHash "web_logs" ( - WebLogCache.all () - |> Seq.ofList - |> Seq.sortBy _.Name - |> Seq.map (fun it -> [| string it.Id; it.Name; it.UrlBase |]) - |> Array.ofSeq) - |> adminView "admin-dashboard" next ctx + let! themes = ctx.Data.Theme.All() + return! adminPage "myWebLog Administration" true next ctx (Views.Admin.dashboard themes) } /// Redirect the user to the admin dashboard @@ -117,43 +95,24 @@ module Category = open MyWebLog.Data // GET /admin/categories - let all : HttpHandler = fun next ctx -> task { - match! TemplateCache.get adminTheme "category-list-body" ctx.Data with - | Ok catListTemplate -> - let! hash = - hashForPage "Categories" - |> withAntiCsrf ctx - |> addViewContext ctx - return! - addToHash "category_list" (catListTemplate.Render hash) hash - |> adminView "category-list" next ctx - | Error message -> return! Error.server message next ctx - } - - // GET /admin/categories/bare - let bare : HttpHandler = fun next ctx -> - hashForPage "Categories" - |> withAntiCsrf ctx - |> adminBareView "category-list-body" next ctx - + let all : HttpHandler = fun next ctx -> + adminPage "Categories" true next ctx Views.WebLog.categoryList // GET /admin/category/{id}/edit let edit 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" }) + | "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) + | Some cat -> return Some ("Edit Category", cat) | None -> return None } match result with | Some (title, cat) -> return! - hashForPage title - |> withAntiCsrf ctx - |> addToHash ViewContext.Model (EditCategoryModel.FromCategory cat) - |> adminBareView "category-edit" next ctx + Views.WebLog.categoryEdit (EditCategoryModel.FromCategory cat) + |> adminBarePage title true next ctx | None -> return! Error.notFound next ctx } @@ -171,16 +130,16 @@ module Category = 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) } + ParentId = if model.ParentId = "" then None else Some (CategoryId model.ParentId) } do! (if model.IsNew then data.Category.Add else data.Category.Update) updatedCat do! CategoryCache.update ctx do! addMessage ctx { UserMessage.Success with Message = "Category saved successfully" } - return! bare next ctx + return! all next ctx | None -> return! Error.notFound next ctx } - // POST /admin/category/{id}/delete - let delete catId : HttpHandler = fun next ctx -> task { + // DELETE /admin/category/{id} + let delete catId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let! result = ctx.Data.Category.Delete (CategoryId catId) ctx.WebLog.Id match result with | CategoryDeleted @@ -194,7 +153,7 @@ module Category = do! addMessage ctx { UserMessage.Success with Message = "Category deleted successfully"; Detail = detail } | CategoryNotFound -> do! addMessage ctx { UserMessage.Error with Message = "Category not found; cannot delete" } - return! bare next ctx + return! all next ctx } @@ -205,19 +164,20 @@ module RedirectRules = // GET /admin/settings/redirect-rules let all : HttpHandler = fun next ctx -> - adminPage "Redirect Rules" true next ctx (Views.Admin.redirectList ctx.WebLog.RedirectRules) + adminPage "Redirect Rules" true next ctx (Views.WebLog.redirectList ctx.WebLog.RedirectRules) // GET /admin/settings/redirect-rules/[index] let edit idx : HttpHandler = fun next ctx -> let titleAndView = if idx = -1 then - Some ("Add", Views.Admin.redirectEdit (EditRedirectRuleModel.FromRule -1 RedirectRule.Empty)) + Some ("Add", Views.WebLog.redirectEdit (EditRedirectRuleModel.FromRule -1 RedirectRule.Empty)) else let rules = ctx.WebLog.RedirectRules if rules.Length < idx || idx < 0 then None else - Some ("Edit", (Views.Admin.redirectEdit (EditRedirectRuleModel.FromRule idx (List.item idx rules)))) + Some + ("Edit", (Views.WebLog.redirectEdit (EditRedirectRuleModel.FromRule idx (List.item idx rules)))) match titleAndView with | Some (title, view) -> adminBarePage $"{title} Redirect Rule" true next ctx view | None -> Error.notFound next ctx @@ -284,7 +244,7 @@ module TagMapping = // GET /admin/settings/tag-mappings let all : HttpHandler = fun next ctx -> task { let! mappings = ctx.Data.TagMap.FindByWebLog ctx.WebLog.Id - return! adminBarePage "Tag Mapping List" true next ctx (Views.Admin.tagMapList mappings) + return! adminBarePage "Tag Mapping List" true next ctx (Views.WebLog.tagMapList mappings) } // GET /admin/settings/tag-mapping/{id}/edit @@ -296,7 +256,7 @@ module TagMapping = match! tagMap with | Some tm -> return! - Views.Admin.tagMapEdit (EditTagMapModel.FromMapping tm) + Views.WebLog.tagMapEdit (EditTagMapModel.FromMapping tm) |> adminBarePage (if isNew then "Add Tag Mapping" else $"Mapping for {tm.Tag} Tag") true next ctx | None -> return! Error.notFound next ctx } @@ -497,7 +457,7 @@ module WebLog = let uploads = [ Database; Disk ] let feeds = ctx.WebLog.Rss.CustomFeeds |> List.map (DisplayCustomFeed.FromFeed (CategoryCache.get ctx)) return! - Views.Admin.webLogSettings + Views.WebLog.webLogSettings (SettingsModel.FromWebLog ctx.WebLog) themes pages uploads (EditRssModel.FromRssOptions ctx.WebLog.Rss) feeds |> adminPage "Web Log Settings" true next ctx diff --git a/src/MyWebLog/Handlers/Feed.fs b/src/MyWebLog/Handlers/Feed.fs index c1e8cdd..8c55dbd 100644 --- a/src/MyWebLog/Handlers/Feed.fs +++ b/src/MyWebLog/Handlers/Feed.fs @@ -2,7 +2,6 @@ module MyWebLog.Handlers.Feed open System -open System.Collections.Generic open System.IO open System.Net open System.ServiceModel.Syndication @@ -446,7 +445,7 @@ let editCustomFeed feedId : HttpHandler = requireAccess WebLogAdmin >=> fun next { Name = string Newsletter; Value = "Newsletter" } { Name = string Blog; Value = "Blog" } ] - Views.Admin.feedEdit (EditCustomFeedModel.FromFeed f) ratings mediums + Views.WebLog.feedEdit (EditCustomFeedModel.FromFeed f) ratings mediums |> adminPage $"""{if feedId = "new" then "Add" else "Edit"} Custom RSS Feed""" true next ctx | None -> Error.notFound next ctx @@ -474,7 +473,7 @@ let saveCustomFeed : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> | None -> return! Error.notFound next ctx } -// POST /admin/settings/rss/{id}/delete +// DELETE /admin/settings/rss/{id} let deleteCustomFeed feedId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let data = ctx.Data match! data.WebLog.FindById ctx.WebLog.Id with diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index 87fe4e5..733f29b 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -114,7 +114,6 @@ let router : HttpHandler = choose [ route "/administration" >=> Admin.Dashboard.admin subRoute "/categor" (requireAccess WebLogAdmin >=> choose [ route "ies" >=> Admin.Category.all - route "ies/bare" >=> Admin.Category.bare routef "y/%s/edit" Admin.Category.edit ]) route "/dashboard" >=> Admin.Dashboard.user @@ -184,11 +183,10 @@ let router : HttpHandler = choose [ routef "/%s/revision/%s/restore" Post.restoreRevision ]) subRoute "/settings" (requireAccess WebLogAdmin >=> choose [ - route "" >=> Admin.WebLog.saveSettings + route "" >=> Admin.WebLog.saveSettings subRoute "/rss" (choose [ - route "" >=> Feed.saveSettings - route "/save" >=> Feed.saveCustomFeed - routef "/%s/delete" Feed.deleteCustomFeed + route "" >=> Feed.saveSettings + route "/save" >=> Feed.saveCustomFeed ]) subRoute "/redirect-rules" (choose [ routef "/%i" Admin.RedirectRules.save @@ -202,13 +200,10 @@ let router : HttpHandler = choose [ route "/new" >=> Admin.Theme.save routef "/%s/delete" Admin.Theme.delete ]) - subRoute "/upload" (choose [ - route "/save" >=> Upload.save - routexp "/delete/(.*)" Upload.deleteFromDisk - routef "/%s/delete" Upload.deleteFromDb - ]) + route "/upload/save" >=> Upload.save ] DELETE >=> validateCsrf >=> choose [ + routef "/category/%s" Admin.Category.delete subRoute "/page" (choose [ routef "/%s" Page.delete routef "/%s/revision/%s" Page.deleteRevision @@ -221,9 +216,14 @@ let router : HttpHandler = choose [ routef "/%s/revisions" Post.purgeRevisions ]) subRoute "/settings" (requireAccess WebLogAdmin >=> choose [ - routef "/user/%s" User.delete routef "/redirect-rules/%i" Admin.RedirectRules.delete + routef "/rss/%s" Feed.deleteCustomFeed routef "/tag-mapping/%s" Admin.TagMapping.delete + routef "/user/%s" User.delete + ]) + subRoute "/upload" (requireAccess WebLogAdmin >=> choose [ + routexp "/disk/(.*)" Upload.deleteFromDisk + routef "/%s" Upload.deleteFromDb ]) ] ]) diff --git a/src/MyWebLog/Handlers/Upload.fs b/src/MyWebLog/Handlers/Upload.fs index aac82d9..a52b037 100644 --- a/src/MyWebLog/Handlers/Upload.fs +++ b/src/MyWebLog/Handlers/Upload.fs @@ -108,30 +108,24 @@ let list : HttpHandler = requireAccess Author >=> fun next ctx -> task { Path = file.Replace($"{path}{slash}", "").Replace(name, "").Replace(slash, '/') UpdatedOn = create Source = string Disk }) - |> List.ofSeq with | :? DirectoryNotFoundException -> [] // This is fine | ex -> warn "Upload" ctx $"Encountered {ex.GetType().Name} listing uploads for {path}:\n{ex.Message}" [] - let allFiles = - dbUploads - |> List.map (DisplayUpload.FromUpload webLog Database) - |> List.append diskUploads - |> List.sortByDescending (fun file -> file.UpdatedOn, file.Path) return! - hashForPage "Uploaded Files" - |> withAntiCsrf ctx - |> addToHash "files" allFiles - |> adminView "upload-list" next ctx + dbUploads + |> Seq.ofList + |> Seq.map (DisplayUpload.FromUpload webLog Database) + |> Seq.append diskUploads + |> Seq.sortByDescending (fun file -> file.UpdatedOn, file.Path) + |> Views.WebLog.uploadList + |> adminPage "Uploaded Files" true next ctx } // GET /admin/upload/new let showNew : HttpHandler = requireAccess Author >=> fun next ctx -> - hashForPage "Upload a File" - |> withAntiCsrf ctx - |> addToHash "destination" (string ctx.WebLog.Uploads) - |> adminView "upload-new" next ctx + adminPage "Upload a File" true next ctx Views.WebLog.uploadNew /// Redirect to the upload list @@ -173,8 +167,8 @@ let save : HttpHandler = requireAccess Author >=> fun next ctx -> task { return! RequestErrors.BAD_REQUEST "Bad request; no file present" next ctx } -// POST /admin/upload/{id}/delete -let deleteFromDb upId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { +// DELETE /admin/upload/{id} +let deleteFromDb upId : HttpHandler = fun next ctx -> task { match! ctx.Data.Upload.Delete (UploadId upId) ctx.WebLog.Id with | Ok fileName -> do! addMessage ctx { UserMessage.Success with Message = $"{fileName} deleted successfully" } @@ -193,8 +187,8 @@ let removeEmptyDirectories (webLog: WebLog) (filePath: string) = path <- String.Join(slash, path.Split slash |> Array.rev |> Array.skip 1 |> Array.rev) else finished <- true -// POST /admin/upload/delete/{**path} -let deleteFromDisk urlParts : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { +// DELETE /admin/upload/disk/{**path} +let deleteFromDisk urlParts : HttpHandler = fun next ctx -> task { let filePath = urlParts |> Seq.skip 1 |> Seq.head let path = Path.Combine(uploadDir, ctx.WebLog.Slug, filePath) if File.Exists path then diff --git a/src/MyWebLog/MyWebLog.fsproj b/src/MyWebLog/MyWebLog.fsproj index 40b0eb6..105c91b 100644 --- a/src/MyWebLog/MyWebLog.fsproj +++ b/src/MyWebLog/MyWebLog.fsproj @@ -14,6 +14,7 @@ + diff --git a/src/MyWebLog/Views/Admin.fs b/src/MyWebLog/Views/Admin.fs index e1e171a..a3933db 100644 --- a/src/MyWebLog/Views/Admin.fs +++ b/src/MyWebLog/Views/Admin.fs @@ -2,493 +2,107 @@ module MyWebLog.Views.Admin open Giraffe.Htmx.Common open Giraffe.ViewEngine -open Giraffe.ViewEngine.Accessibility open Giraffe.ViewEngine.Htmx open MyWebLog open MyWebLog.ViewModels -/// The main dashboard -let dashboard (model: DashboardModel) app = [ - h2 [ _class "my-3" ] [ txt app.WebLog.Name; raw " • Dashboard" ] - article [ _class "container" ] [ - div [ _class "row" ] [ - section [ _class "col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3" ] [ - div [ _class "card" ] [ - header [ _class "card-header text-white bg-primary" ] [ raw "Posts" ] - div [ _class "card-body" ] [ - h6 [ _class "card-subtitle text-muted pb-3" ] [ - raw "Published " - span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.Posts) ] - raw "  Drafts " - span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.Drafts) ] - ] - if app.IsAuthor then - a [ _href (relUrl app "admin/posts"); _class "btn btn-secondary me-2" ] [ raw "View All" ] - a [ _href (relUrl app "admin/post/new/edit"); _class "btn btn-primary" ] [ - raw "Write a New Post" - ] - ] - ] - ] - section [ _class "col-lg-5 col-xl-4 pb-3" ] [ - div [ _class "card" ] [ - header [ _class "card-header text-white bg-primary" ] [ raw "Pages" ] - div [ _class "card-body" ] [ - h6 [ _class "card-subtitle text-muted pb-3" ] [ - raw "All " - span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.Pages) ] - raw "  Shown in Page List " - span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.ListedPages) ] - ] - if app.IsAuthor then - a [ _href (relUrl app "admin/pages"); _class "btn btn-secondary me-2" ] [ raw "View All" ] - a [ _href (relUrl app "admin/page/new/edit"); _class "btn btn-primary" ] [ - raw "Create a New Page" - ] - ] +/// The administrator dashboard +let dashboard (themes: Theme list) app = [ + let templates = TemplateCache.allNames () + let cacheBaseUrl = relUrl app "admin/cache/" + let webLogCacheUrl = $"{cacheBaseUrl}web-log/" + let themeCacheUrl = $"{cacheBaseUrl}theme/" + let webLogDetail (webLog: WebLog) = + let refreshUrl = $"{webLogCacheUrl}{webLog.Id}/refresh" + div [ _class "row mwl-table-detail" ] [ + div [ _class "col" ] [ + txt webLog.Name; br [] + small [] [ + span [ _class "text-muted" ] [ raw webLog.UrlBase ]; br [] + a [ _href refreshUrl; _hxPost refreshUrl ] [ raw "Refresh" ] ] ] ] - div [ _class "row" ] [ - section [ _class "col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3" ] [ - div [ _class "card" ] [ - header [ _class "card-header text-white bg-secondary" ] [ raw "Categories" ] - div [ _class "card-body" ] [ - h6 [ _class "card-subtitle text-muted pb-3"] [ - raw "All " - span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.Categories) ] - raw "  Top Level " - span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.TopLevelCategories) ] - ] - if app.IsWebLogAdmin then - a [ _href (relUrl app "admin/categories"); _class "btn btn-secondary me-2" ] [ - raw "View All" - ] - a [ _href (relUrl app "admin/category/new/edit"); _class "btn btn-secondary" ] [ - raw "Add a New Category" - ] - ] + let themeDetail (theme: Theme) = + let refreshUrl = $"{themeCacheUrl}{theme.Id}/refresh" + div [ _class "row mwl-table-detail" ] [ + div [ _class "col-8" ] [ + txt theme.Name; br [] + small [] [ + span [ _class "text-muted" ] [ txt (string theme.Id); raw " • " ] + a [ _href refreshUrl; _hxPost refreshUrl ] [ raw "Refresh" ] ] ] + div [ _class "col-4" ] [ + raw (templates |> List.filter _.StartsWith(string theme.Id) |> List.length |> string) + ] ] - if app.IsWebLogAdmin then - div [ _class "row pb-3" ] [ - div [ _class "col text-end" ] [ - a [ _href (relUrl app "admin/settings"); _class "btn btn-secondary" ] [ raw "Modify Settings" ] - ] - ] - ] -] - -/// Custom RSS feed edit form -let feedEdit (model: EditCustomFeedModel) (ratings: MetaItem list) (mediums: MetaItem list) app = [ h2 [ _class "my-3" ] [ raw app.PageTitle ] article [] [ - form [ _action (relUrl app "admin/settings/rss/save"); _method "post"; _class "container" ] [ - antiCsrf app - input [ _type "hidden"; _name "Id"; _value model.Id ] - div [ _class "row pb-3" ] [ - div [ _class "col" ] [ - a [ _href (relUrl app "admin/settings#rss-settings") ] [ raw "« Back to Settings" ] - ] - ] - div [ _class "row pb-3" ] [ - div [ _class "col-12 col-lg-6" ] [ - fieldset [ _class "container pb-0" ] [ - legend [] [ raw "Identification" ] - div [ _class "row" ] [ - div [ _class "col" ] [ - textField [ _required ] (nameof model.Path) "Relative Feed Path" model.Path [ - span [ _class "form-text fst-italic" ] [ raw "Appended to "; txt app.WebLog.UrlBase ] - ] - ] - ] - div [ _class "row" ] [ - div [ _class "col py-3 d-flex align-self-center justify-content-center" ] [ - checkboxSwitch [ _onclick "Admin.checkPodcast()"; if model.IsPodcast then _checked ] - (nameof model.IsPodcast) "This Is a Podcast Feed" model.IsPodcast [] - ] - ] - ] - ] - div [ _class "col-12 col-lg-6" ] [ - fieldset [ _class "container pb-0" ] [ - legend [] [ raw "Feed Source" ] - div [ _class "row d-flex align-items-center" ] [ - div [ _class "col-1 d-flex justify-content-end pb-3" ] [ - div [ _class "form-check form-check-inline me-0" ] [ - input [ _type "radio"; _name (nameof model.SourceType); _id "SourceTypeCat" - _class "form-check-input"; _value "category" - if model.SourceType <> "tag" then _checked - _onclick "Admin.customFeedBy('category')" ] - label [ _for "SourceTypeCat"; _class "form-check-label d-none" ] [ raw "Category" ] - ] - ] - div [ _class "col-11 pb-3" ] [ - let cats = - app.Categories - |> Seq.ofArray - |> Seq.map (fun c -> - let parents = - if c.ParentNames.Length = 0 then "" - else - c.ParentNames - |> Array.map (fun it -> $"{it} ⟩ ") - |> String.concat "" - { Name = c.Id; Value = $"{parents} {c.Name}".Trim() }) - |> Seq.append [ { Name = ""; Value = "– Select Category –" } ] - |> List.ofSeq - selectField [ _id "SourceValueCat"; _required - if model.SourceType = "tag" then _disabled ] - (nameof model.SourceValue) "Category" model.SourceValue cats (_.Name) - (_.Value) [] - ] - div [ _class "col-1 d-flex justify-content-end pb-3" ] [ - div [ _class "form-check form-check-inline me-0" ] [ - input [ _type "radio"; _name (nameof model.SourceType); _id "SourceTypeTag" - _class "form-check-input"; _value "tag" - if model.SourceType= "tag" then _checked - _onclick "Admin.customFeedBy('tag')" ] - label [ _for "sourceTypeTag"; _class "form-check-label d-none" ] [ raw "Tag" ] - ] - ] - div [ _class "col-11 pb-3" ] [ - textField [ _id "SourceValueTag"; _required - if model.SourceType <> "tag" then _disabled ] - (nameof model.SourceValue) "Tag" - (if model.SourceType = "tag" then model.SourceValue else "") [] - ] - ] - ] - ] - ] - div [ _class "row pb-3" ] [ - div [ _class "col" ] [ - fieldset [ _class "container"; _id "podcastFields"; if not model.IsPodcast then _disabled ] [ - legend [] [ raw "Podcast Settings" ] - div [ _class "row" ] [ - div [ _class "col-12 col-md-5 col-lg-4 offset-lg-1 pb-3" ] [ - textField [ _required ] (nameof model.Title) "Title" model.Title [] - ] - div [ _class "col-12 col-md-4 col-lg-4 pb-3" ] [ - textField [] (nameof model.Subtitle) "Podcast Subtitle" model.Subtitle [] - ] - div [ _class "col-12 col-md-3 col-lg-2 pb-3" ] [ - numberField [ _required ] (nameof model.ItemsInFeed) "# Episodes" model.ItemsInFeed [] - ] - ] - div [ _class "row" ] [ - div [ _class "col-12 col-md-5 col-lg-4 offset-lg-1 pb-3" ] [ - textField [ _required ] (nameof model.AppleCategory) "iTunes Category" - model.AppleCategory [ - span [ _class "form-text fst-italic" ] [ - a [ _href "https://www.thepodcasthost.com/planning/itunes-podcast-categories/" - _target "_blank"; _rel "noopener" ] [ - raw "iTunes Category / Subcategory List" - ] - ] - ] - ] - div [ _class "col-12 col-md-4 pb-3" ] [ - textField [] (nameof model.AppleSubcategory) "iTunes Subcategory" model.AppleSubcategory - [] - ] - div [ _class "col-12 col-md-3 col-lg-2 pb-3" ] [ - selectField [ _required ] (nameof model.Explicit) "Explicit Rating" model.Explicit - ratings (_.Name) (_.Value) [] - ] - ] - div [ _class "row" ] [ - div [ _class "col-12 col-md-6 col-lg-4 offset-xxl-1 pb-3" ] [ - textField [ _required ] (nameof model.DisplayedAuthor) "Displayed Author" - model.DisplayedAuthor [] - ] - div [ _class "col-12 col-md-6 col-lg-4 pb-3" ] [ - emailField [ _required ] (nameof model.Email) "Author E-mail" model.Email [ - span [ _class "form-text fst-italic" ] [ - raw "For iTunes, must match registered e-mail" - ] - ] - ] - div [ _class "col-12 col-sm-5 col-md-4 col-lg-4 col-xl-3 offset-xl-1 col-xxl-2 offset-xxl-0 pb-3" ] [ - textField [] (nameof model.DefaultMediaType) "Default Media Type" - model.DefaultMediaType [ - span [ _class "form-text fst-italic" ] [ raw "Optional; blank for no default" ] - ] - ] - div [ _class "col-12 col-sm-7 col-md-8 col-lg-10 offset-lg-1 pb-3" ] [ - textField [ _required ] (nameof model.ImageUrl) "Image URL" model.ImageUrl [ - span [ _class "form-text fst-italic"] [ - raw "Relative URL will be appended to "; txt app.WebLog.UrlBase; raw "/" - ] - ] - ] - ] - div [ _class "row pb-3" ] [ - div [ _class "col-12 col-lg-10 offset-lg-1" ] [ - textField [ _required ] (nameof model.Summary) "Summary" model.Summary [ - span [ _class "form-text fst-italic" ] [ raw "Displayed in podcast directories" ] - ] - ] - ] - div [ _class "row pb-3" ] [ - div [ _class "col-12 col-lg-10 offset-lg-1" ] [ - textField [] (nameof model.MediaBaseUrl) "Media Base URL" model.MediaBaseUrl [ - span [ _class "form-text fst-italic" ] [ - raw "Optional; prepended to episode media file if present" - ] - ] - ] - ] - div [ _class "row" ] [ - div [ _class "col-12 col-lg-5 offset-lg-1 pb-3" ] [ - textField [] (nameof model.FundingUrl) "Funding URL" model.FundingUrl [ - span [ _class "form-text fst-italic" ] [ - raw "Optional; URL describing donation options for this podcast, " - raw "relative URL supported" - ] - ] - ] - div [ _class "col-12 col-lg-5 pb-3" ] [ - textField [ _maxlength "128" ] (nameof model.FundingText) "Funding Text" - model.FundingText [ - span [ _class "form-text fst-italic" ] [ raw "Optional; text for the funding link" ] - ] - ] - ] - div [ _class "row pb-3" ] [ - div [ _class "col-8 col-lg-5 offset-lg-1 pb-3" ] [ - textField [] (nameof model.PodcastGuid) "Podcast GUID" model.PodcastGuid [ - span [ _class "form-text fst-italic" ] [ - raw "Optional; v5 UUID uniquely identifying this podcast; " - raw "once entered, do not change this value (" - a [ _href "https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#guid" - _target "_blank"; _rel "noopener" ] [ - raw "documentation" - ]; raw ")" - ] - ] - ] - div [ _class "col-4 col-lg-3 offset-lg-2 pb-3" ] [ - selectField [] (nameof model.Medium) "Medium" model.Medium mediums (_.Name) (_.Value) [ - span [ _class "form-text fst-italic" ] [ - raw "Optional; medium of the podcast content (" - a [ _href "https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#medium" - _target "_blank"; _rel "noopener" ] [ - raw "documentation" - ]; raw ")" - ] - ] - ] - ] - ] - ] - ] - div [ _class "row pb-3" ] [ div [ _class "col text-center" ] [ saveButton ] ] + fieldset [ _class "container mb-3 pb-0" ] [ + legend [] [ raw "Themes" ] + span [ _hxGet (relUrl app "admin/theme/list"); _hxTrigger HxTrigger.Load; _hxSwap HxSwap.OuterHtml ] [] ] - ] -] - - -/// Redirect Rule edit form -let redirectEdit (model: EditRedirectRuleModel) app = [ - let url = relUrl app $"admin/settings/redirect-rules/{model.RuleId}" - h3 [] [ raw (if model.RuleId < 0 then "Add" else "Edit"); raw " Redirect Rule" ] - form [ _action url; _hxPost url; _hxTarget "body"; _method "post"; _class "container" ] [ - antiCsrf app - input [ _type "hidden"; _name "RuleId"; _value (string model.RuleId) ] - div [ _class "row" ] [ - div [ _class "col-12 col-lg-5 mb-3" ] [ - textField [ _autofocus; _required ] (nameof model.From) "From" model.From [ - span [ _class "form-text" ] [ raw "From local URL/pattern" ] - ] - ] - div [ _class "col-12 col-lg-5 mb-3" ] [ - textField [ _required ] (nameof model.To) "To" model.To [ - span [ _class "form-text" ] [ raw "To URL/pattern" ] - ] - ] - div [ _class "col-12 col-lg-2 mb-3" ] [ - checkboxSwitch [] (nameof model.IsRegex) "Use RegEx" model.IsRegex [] - ] - ] - if model.RuleId < 0 then - div [ _class "row mb-3" ] [ - div [ _class "col-12 text-center" ] [ - label [ _class "me-1" ] [ raw "Add Rule" ] - div [ _class "btn-group btn-group-sm"; _roleGroup; _ariaLabel "New rule placement button group" ] [ - input [ _type "radio"; _name "InsertAtTop"; _id "at_top"; _class "btn-check"; _value "true" ] - label [ _class "btn btn-sm btn-outline-secondary"; _for "at_top" ] [ raw "Top" ] - input [ _type "radio"; _name "InsertAtTop"; _id "at_bot"; _class "btn-check"; _value "false" - _checked ] - label [ _class "btn btn-sm btn-outline-secondary"; _for "at_bot" ] [ raw "Bottom" ] - ] - ] - ] - div [ _class "row mb-3" ] [ - div [ _class "col text-center" ] [ - saveButton; raw "   " - a [ _href (relUrl app "admin/settings/redirect-rules"); _class "btn btn-sm btn-secondary ms-3" ] [ - raw "Cancel" - ] - ] - ] - ] -] - - -/// The list of current redirect rules -let redirectList (model: RedirectRule list) app = [ - // Generate the detail for a redirect rule - let ruleDetail idx (rule: RedirectRule) = - let ruleId = $"rule_{idx}" - div [ _class "row mwl-table-detail"; _id ruleId ] [ - div [ _class "col-5 no-wrap" ] [ - txt rule.From; br [] - small [] [ - let ruleUrl = relUrl app $"admin/settings/redirect-rules/{idx}" - a [ _href ruleUrl; _hxTarget $"#{ruleId}"; _hxSwap $"{HxSwap.InnerHtml} show:#{ruleId}:top" ] [ - raw "Edit" - ] - if idx > 0 then - span [ _class "text-muted" ] [ raw " • " ] - a [ _href $"{ruleUrl}/up"; _hxPost $"{ruleUrl}/up" ] [ raw "Move Up" ] - if idx <> model.Length - 1 then - span [ _class "text-muted" ] [ raw " • " ] - a [ _href $"{ruleUrl}/down"; _hxPost $"{ruleUrl}/down" ] [ raw "Move Down" ] - span [ _class "text-muted" ] [ raw " • " ] - a [ _class "text-danger"; _href ruleUrl; _hxDelete ruleUrl - _hxConfirm "Are you sure you want to delete this redirect rule?" ] [ - raw "Delete" - ] - ] - ] - div [ _class "col-5" ] [ txt rule.To ] - div [ _class "col-2 text-center" ] [ yesOrNo rule.IsRegex ] - ] - h2 [ _class "my-3" ] [ raw app.PageTitle ] - article [] [ - p [ _class "mb-3" ] [ - a [ _href (relUrl app "admin/settings") ] [ raw "« Back to Settings" ] - ] - div [ _class "container" ] [ - div [ _class "row" ] [ - div [ _class "col" ] [ - a [ _href (relUrl app "admin/settings/redirect-rules/-1"); _class "btn btn-primary btn-sm mb-3" - _hxTarget "#rule_new" ] [ - raw "Add Redirect Rule" - ] - ] + fieldset [ _class "container mb-3 pb-0" ] [ + legend [] [ raw "Caches" ] + p [ _class "pb-2" ] [ + raw "myWebLog uses a few caches to ensure that it serves pages as fast as possible. (" + a [ _href "https://bitbadger.solutions/open-source/myweblog/advanced.html#cache-management" + _target "_blank" ] [ + raw "more information" + ]; raw ")" ] div [ _class "row" ] [ - div [ _class "col" ] [ - if List.isEmpty model then - div [ _id "rule_new" ] [ - p [ _class "text-muted text-center fst-italic" ] [ - raw "This web log has no redirect rules defined" + div [ _class "col-12 col-lg-6 pb-3" ] [ + div [ _class "card" ] [ + header [ _class "card-header text-white bg-secondary" ] [ raw "Web Logs" ] + div [ _class "card-body pb-0" ] [ + h6 [ _class "card-subtitle text-muted pb-3" ] [ + raw "These caches include the page list and categories for each web log" + ] + let webLogUrl = $"{cacheBaseUrl}web-log/" + form [ _method "post"; _class "container g-0"; _hxNoBoost; _hxTarget "body" + _hxSwap $"{HxSwap.InnerHtml} show:window:top" ] [ + antiCsrf app + button [ _type "submit"; _class "btn btn-sm btn-primary mb-2" + _hxPost $"{webLogUrl}all/refresh" ] [ + raw "Refresh All" + ] + div [ _class "row mwl-table-heading" ] [ div [ _class "col" ] [ raw "Web Log" ] ] + yield! WebLogCache.all () |> List.sortBy _.Name |> List.map webLogDetail ] ] - else - div [ _class "container g-0" ] [ - div [ _class "row mwl-table-heading" ] [ - div [ _class "col-5" ] [ raw "From" ] - div [ _class "col-5" ] [ raw "To" ] - div [ _class "col-2 text-center" ] [ raw "RegEx?" ] - ] - ] - div [ _class "row mwl-table-detail"; _id "rule_new" ] [] - form [ _method "post"; _class "container g-0"; _hxTarget "body" ] [ - antiCsrf app; yield! List.mapi ruleDetail model - ] + ] ] - ] - ] - p [ _class "mt-3 text-muted fst-italic text-center" ] [ - raw "This is an advanced feature; please " - a [ _href "https://bitbadger.solutions/open-source/myweblog/advanced.html#redirect-rules" - _target "_blank" ] [ - raw "read and understand the documentation on this feature" - ] - raw " before adding rules." - ] - ] -] - - -/// Edit a tag mapping -let tagMapEdit (model: EditTagMapModel) app = [ - h5 [ _class "my-3" ] [ txt app.PageTitle ] - form [ _hxPost (relUrl app "admin/settings/tag-mapping/save"); _method "post"; _class "container" - _hxTarget "#tagList"; _hxSwap $"{HxSwap.OuterHtml} show:window:top" ] [ - antiCsrf app - input [ _type "hidden"; _name "Id"; _value model.Id ] - div [ _class "row mb-3" ] [ - div [ _class "col-6 col-lg-4 offset-lg-2" ] [ - textField [ _autofocus; _required ] (nameof model.Tag) "Tag" model.Tag [] - ] - div [ _class "col-6 col-lg-4" ] [ - textField [ _required ] (nameof model.UrlValue) "URL Value" model.UrlValue [] - ] - ] - div [ _class "row mb-3" ] [ - div [ _class "col text-center" ] [ - saveButton; raw "   " - a [ _href (relUrl app "admin/settings/tag-mappings"); _class "btn btn-sm btn-secondary ms-3" ] [ - raw "Cancel" + div [ _class "col-12 col-lg-6 pb-3" ] [ + div [ _class "card" ] [ + header [ _class "card-header text-white bg-secondary" ] [ raw "Themes" ] + div [ _class "card-body pb-0" ] [ + h6 [ _class "card-subtitle text-muted pb-3" ] [ + raw "The theme template cache is filled on demand as pages are displayed; " + raw "refreshing a theme with no cached templates will still refresh its asset cache" + ] + form [ _method "post"; _class "container g-0"; _hxNoBoost; _hxTarget "body" + _hxSwap $"{HxSwap.InnerHtml} show:window:top" ] [ + antiCsrf app + button [ _type "submit"; _class "btn btn-sm btn-primary mb-2" + _hxPost $"{themeCacheUrl}all/refresh" ] [ + raw "Refresh All" + ] + div [ _class "row mwl-table-heading" ] [ + div [ _class "col-8" ] [ raw "Theme" ]; div [ _class "col-4" ] [ raw "Cached" ] + ] + yield! themes |> List.filter (fun t -> t.Id <> ThemeId "admin") |> List.map themeDetail + ] + ] + ] ] ] ] ] ] - -/// Display a list of the web log's current tag mappings -let tagMapList (model: TagMap list) app = - let tagMapDetail (map: TagMap) = - let url = relUrl app $"admin/settings/tag-mapping/{map.Id}" - div [ _class "row mwl-table-detail"; _id $"tag_{map.Id}" ] [ - div [ _class "col no-wrap" ] [ - txt map.Tag; br [] - small [] [ - a [ _href $"{url}/edit"; _hxTarget $"#tag_{map.Id}" - _hxSwap $"{HxSwap.InnerHtml} show:#tag_{map.Id}:top" ] [ - raw "Edit" - ] - span [ _class "text-muted" ] [ raw " • " ] - a [ _href url; _hxDelete url; _class "text-danger" - _hxConfirm $"Are you sure you want to delete the mapping for “{map.Tag}”? This action cannot be undone." ] [ - raw "Delete" - ] - ] - ] - div [ _class "col" ] [ txt map.UrlValue ] - ] - div [ _id "tagList"; _class "container" ] [ - div [ _class "row" ] [ - div [ _class "col" ] [ - if List.isEmpty model then - div [ _id "tag_new" ] [ - p [ _class "text-muted text-center fst-italic" ] [ raw "This web log has no tag mappings" ] - ] - else - div [ _class "container g-0" ] [ - div [ _class "row mwl-table-heading" ] [ - div [ _class "col" ] [ raw "Tag" ] - div [ _class "col" ] [ raw "URL Value" ] - ] - ] - form [ _method "post"; _class "container g-0"; _hxTarget "#tagList"; _hxSwap HxSwap.OuterHtml ] [ - antiCsrf app - div [ _class "row mwl-table-detail"; _id "tag_new" ] [] - yield! List.map tagMapDetail model - ] - ] - ] - ] - |> List.singleton - - /// Display a list of themes let themeList (model: DisplayTheme list) app = let themeCol = "col-12 col-md-6" @@ -574,185 +188,3 @@ let themeUpload app = ] ] |> List.singleton - - -/// Web log settings page -let webLogSettings - (model: SettingsModel) (themes: Theme list) (pages: Page list) (uploads: UploadDestination list) - (rss: EditRssModel) (feeds: DisplayCustomFeed list) app = [ - h2 [ _class "my-3" ] [ txt app.WebLog.Name; raw " Settings" ] - article [] [ - p [ _class "text-muted" ] [ - raw "Go to: "; a [ _href "#users" ] [ raw "Users" ]; raw " • " - a [ _href "#rss-settings" ] [ raw "RSS Settings" ]; raw " • " - a [ _href "#tag-mappings" ] [ raw "Tag Mappings" ]; raw " • " - a [ _href (relUrl app "admin/settings/redirect-rules") ] [ raw "Redirect Rules" ] - ] - fieldset [ _class "container mb-3" ] [ - legend [] [ raw "Web Log Settings" ] - form [ _action (relUrl app "admin/settings"); _method "post" ] [ - antiCsrf app - div [ _class "container g-0" ] [ - div [ _class "row" ] [ - div [ _class "col-12 col-md-6 col-xl-4 pb-3" ] [ - textField [ _required; _autofocus ] (nameof model.Name) "Name" model.Name [] - ] - div [ _class "col-12 col-md-6 col-xl-4 pb-3" ] [ - textField [ _required ] (nameof model.Slug) "Slug" model.Slug [ - span [ _class "form-text" ] [ - span [ _class "badge rounded-pill bg-warning text-dark" ] [ raw "WARNING" ] - raw " changing this value may break links (" - a [ _href "https://bitbadger.solutions/open-source/myweblog/configuring.html#blog-settings" - _target "_blank" ] [ - raw "more" - ]; raw ")" - ] - ] - ] - div [ _class "col-12 col-md-6 col-xl-4 pb-3" ] [ - textField [] (nameof model.Subtitle) "Subtitle" model.Subtitle [] - ] - div [ _class "col-12 col-md-6 col-xl-4 offset-xl-1 pb-3" ] [ - selectField [ _required ] (nameof model.ThemeId) "Theme" model.ThemeId themes - (fun t -> string t.Id) (fun t -> $"{t.Name} (v{t.Version})") [] - ] - div [ _class "col-12 col-md-6 offset-md-1 col-xl-4 offset-xl-0 pb-3" ] [ - selectField [ _required ] (nameof model.DefaultPage) "Default Page" model.DefaultPage pages - (fun p -> string p.Id) (_.Title) [] - ] - div [ _class "col-12 col-md-4 col-xl-2 pb-3" ] [ - numberField [ _required; _min "0"; _max "50" ] (nameof model.PostsPerPage) "Posts per Page" - model.PostsPerPage [] - ] - ] - div [ _class "row" ] [ - div [ _class "col-12 col-md-4 col-xl-3 offset-xl-2 pb-3" ] [ - textField [ _required ] (nameof model.TimeZone) "Time Zone" model.TimeZone [] - ] - div [ _class "col-12 col-md-4 col-xl-2" ] [ - checkboxSwitch [] (nameof model.AutoHtmx) "Auto-Load htmx" model.AutoHtmx [] - span [ _class "form-text fst-italic" ] [ - a [ _href "https://htmx.org"; _target "_blank"; _rel "noopener" ] [ - raw "What is this?" - ] - ] - ] - div [ _class "col-12 col-md-4 col-xl-3 pb-3" ] [ - selectField [] (nameof model.Uploads) "Default Upload Destination" model.Uploads uploads - string string [] - ] - ] - div [ _class "row pb-3" ] [ - div [ _class "col text-center" ] [ - button [ _type "submit"; _class "btn btn-primary" ] [ raw "Save Changes" ] - ] - ] - ] - ] - ] - fieldset [ _id "users"; _class "container mb-3 pb-0" ] [ - legend [] [ raw "Users" ] - span [ _hxGet (relUrl app "admin/settings/users"); _hxTrigger HxTrigger.Load; _hxSwap HxSwap.OuterHtml ] [] - ] - fieldset [ _id "rss-settings"; _class "container mb-3 pb-0" ] [ - legend [] [ raw "RSS Settings" ] - form [ _action (relUrl app "admin/settings/rss"); _method "post"; _class "container g-0" ] [ - antiCsrf app - div [ _class "row pb-3" ] [ - div [ _class "col col-xl-8 offset-xl-2" ] [ - fieldset [ _class "d-flex justify-content-evenly flex-row" ] [ - legend [] [ raw "Feeds Enabled" ] - checkboxSwitch [] (nameof rss.IsFeedEnabled) "All Posts" rss.IsFeedEnabled [] - checkboxSwitch [] (nameof rss.IsCategoryEnabled) "Posts by Category" rss.IsCategoryEnabled - [] - checkboxSwitch [] (nameof rss.IsTagEnabled) "Posts by Tag" rss.IsTagEnabled [] - ] - ] - ] - div [ _class "row" ] [ - div [ _class "col-12 col-sm-6 col-md-3 col-xl-2 offset-xl-2 pb-3" ] [ - textField [] (nameof rss.FeedName) "Feed File Name" rss.FeedName [ - span [ _class "form-text" ] [ raw "Default is "; code [] [ raw "feed.xml" ] ] - ] - ] - div [ _class "col-12 col-sm-6 col-md-4 col-xl-2 pb-3" ] [ - numberField [ _required; _min "0" ] (nameof rss.ItemsInFeed) "Items in Feed" rss.ItemsInFeed [ - span [ _class "form-text" ] [ - raw "Set to “0” to use “Posts per Page” setting (" - raw (string app.WebLog.PostsPerPage); raw ")" - ] - ] - ] - div [ _class "col-12 col-md-5 col-xl-4 pb-3" ] [ - textField [] (nameof rss.Copyright) "Copyright String" rss.Copyright [ - span [ _class "form-text" ] [ - raw "Can be a " - a [ _href "https://creativecommons.org/share-your-work/"; _target "_blank" - _rel "noopener" ] [ - raw "Creative Commons license string" - ] - ] - ] - ] - ] - div [ _class "row pb-3" ] [ - div [ _class "col text-center" ] [ - button [ _type "submit"; _class "btn btn-primary" ] [ raw "Save Changes" ] - ] - ] - ] - fieldset [ _class "container mb-3 pb-0" ] [ - legend [] [ raw "Custom Feeds" ] - a [ _class "btn btn-sm btn-secondary"; _href (relUrl app "admin/settings/rss/new/edit") ] [ - raw "Add a New Custom Feed" - ] - if feeds.Length = 0 then - p [ _class "text-muted fst-italic text-center" ] [ raw "No custom feeds defined" ] - else - form [ _method "post"; _class "container g-0"; _hxTarget "body" ] [ - antiCsrf app - div [ _class "row mwl-table-heading" ] [ - div [ _class "col-12 col-md-6" ] [ - span [ _class "d-md-none" ] [ raw "Feed" ] - span [ _class "d-none d-md-inline" ] [ raw "Source" ] - ] - div [ _class $"col-12 col-md-6 d-none d-md-inline-block" ] [ raw "Relative Path" ] - ] - for feed in feeds do - div [ _class "row mwl-table-detail" ] [ - div [ _class "col-12 col-md-6" ] [ - txt feed.Source - if feed.IsPodcast then - raw "   "; span [ _class "badge bg-primary" ] [ raw "PODCAST" ] - br [] - small [] [ - let feedUrl = relUrl app $"admin/settings/rss/{feed.Id}" - a [ _href (relUrl app feed.Path); _target "_blank" ] [ raw "View Feed" ] - span [ _class "text-muted" ] [ raw " • " ] - a [ _href $"{feedUrl}/edit" ] [ raw "Edit" ] - span [ _class "text-muted" ] [ raw " • " ] - a [ _href feedUrl; _hxDelete feedUrl; _class "text-danger" - _hxConfirm $"Are you sure you want to delete the custom RSS feed based on {feed.Source}? This action cannot be undone." ] [ - raw "Delete" - ] - ] - ] - div [ _class "col-12 col-md-6" ] [ - small [ _class "d-md-none" ] [ raw "Served at "; txt feed.Path ] - span [ _class "d-none d-md-inline" ] [ txt feed.Path ] - ] - ] - ] - ] - ] - fieldset [ _id "tag-mappings"; _class "container mb-3 pb-0" ] [ - legend [] [ raw "Tag Mappings" ] - a [ _href (relUrl app "admin/settings/tag-mapping/new/edit"); _class "btn btn-primary btn-sm mb-3" - _hxTarget "#tag_new" ] [ - raw "Add a New Tag Mapping" - ] - span [ _hxGet (relUrl app "admin/settings/tag-mappings"); _hxTrigger HxTrigger.Load - _hxSwap HxSwap.OuterHtml ] [] - ] - ] -] diff --git a/src/MyWebLog/Views/Helpers.fs b/src/MyWebLog/Views/Helpers.fs index 5372127..a087d2c 100644 --- a/src/MyWebLog/Views/Helpers.fs +++ b/src/MyWebLog/Views/Helpers.fs @@ -74,6 +74,9 @@ let txt = encodedText /// Shorthand for raw text in a template let raw = rawText +/// Rel attribute to prevent opener information from being provided to the new window +let _relNoOpener = _rel "noopener" + /// The pattern for a long date let longDatePattern = ZonedDateTimePattern.CreateWithInvariantCulture("MMMM d, yyyy", DateTimeZoneProviders.Tzdb) @@ -136,7 +139,7 @@ let passwordField attrs name labelText value extra = /// Create a select (dropdown) field let selectField<'T, 'a> - attrs name labelText value (values: 'T list) (idFunc: 'T -> 'a) (displayFunc: 'T -> string) extra = + attrs name labelText value (values: 'T seq) (idFunc: 'T -> 'a) (displayFunc: 'T -> string) extra = div [ _class "form-floating" ] [ select ([ _name name; _id name; _class "form-control" ] |> List.append attrs) [ for item in values do @@ -161,6 +164,10 @@ let checkboxSwitch attrs name labelText (value: bool) extra = let saveButton = button [ _type "submit"; _class "btn btn-sm btn-primary" ] [ raw "Save Changes" ] +/// A spacer bullet to use between action links +let actionSpacer = + span [ _class "text-muted" ] [ raw " • " ] + /// Functions for generating content in varying layouts module Layout = diff --git a/src/MyWebLog/Views/Post.fs b/src/MyWebLog/Views/Post.fs index fa0de21..d7b9499 100644 --- a/src/MyWebLog/Views/Post.fs +++ b/src/MyWebLog/Views/Post.fs @@ -66,7 +66,7 @@ let chapterEdit (model: EditChapterModel) app = [ textField (_required :: attrs) (nameof model.LocationGeo) "Geo URL" model.LocationGeo [ em [ _class "form-text" ] [ a [ _href "https://github.com/Podcastindex-org/podcast-namespace/blob/main/location/location.md#geo-recommended" - _target "_blank"; _rel "noopener" ] [ + _target "_blank"; _relNoOpener ] [ raw "see spec" ] ] @@ -76,10 +76,10 @@ let chapterEdit (model: EditChapterModel) app = [ textField attrs (nameof model.LocationOsm) "OpenStreetMap ID" model.LocationOsm [ em [ _class "form-text" ] [ raw "Optional; " - a [ _href "https://www.openstreetmap.org/"; _target "_blank"; _rel "noopener" ] [ raw "get ID" ] + a [ _href "https://www.openstreetmap.org/"; _target "_blank"; _relNoOpener ] [ raw "get ID" ] raw ", " a [ _href "https://github.com/Podcastindex-org/podcast-namespace/blob/main/location/location.md#osm-recommended" - _target "_blank"; _rel "noopener" ] [ + _target "_blank"; _relNoOpener ] [ raw "see spec" ] ] diff --git a/src/MyWebLog/Views/WebLog.fs b/src/MyWebLog/Views/WebLog.fs new file mode 100644 index 0000000..2aeff71 --- /dev/null +++ b/src/MyWebLog/Views/WebLog.fs @@ -0,0 +1,875 @@ +module MyWebLog.Views.WebLog + +open Giraffe.Htmx.Common +open Giraffe.ViewEngine +open Giraffe.ViewEngine.Accessibility +open Giraffe.ViewEngine.Htmx +open MyWebLog +open MyWebLog.ViewModels + +/// Form to add or edit a category +let categoryEdit (model: EditCategoryModel) app = + div [ _class "col-12" ] [ + h5 [ _class "my-3" ] [ raw app.PageTitle ] + form [ _action (relUrl app "admin/category/save"); _method "post"; _class "container" ] [ + antiCsrf app + input [ _type "hidden"; _name (nameof model.CategoryId); _value model.CategoryId ] + div [ _class "row" ] [ + div [ _class "col-12 col-sm-6 col-lg-4 col-xxl-3 offset-xxl-1 mb-3" ] [ + textField [ _required; _autofocus ] (nameof model.Name) "Name" model.Name [] + ] + div [ _class "col-12 col-sm-6 col-lg-4 col-xxl-3 mb-3" ] [ + textField [ _required ] (nameof model.Slug) "Slug" model.Slug [] + ] + div [ _class "col-12 col-lg-4 col-xxl-3 offset-xxl-1 mb-3" ] [ + let cats = + app.Categories + |> Seq.ofArray + |> Seq.filter (fun c -> c.Id <> model.CategoryId) + |> Seq.map (fun c -> + let parents = + c.ParentNames + |> Array.map (fun it -> $"{it}   » ") + |> String.concat "" + { Name = c.Id; Value = $"{parents}{c.Name}" }) + |> Seq.append [ { Name = ""; Value = "– None –" } ] + selectField [] (nameof model.ParentId) "Parent Category" model.ParentId cats (_.Name) (_.Value) [] + ] + div [ _class "col-12 col-xl-10 offset-xl-1 mb-3" ] [ + textField [] (nameof model.Description) "Description" model.Description [] + ] + ] + div [ _class "row mb-3" ] [ + div [ _class "col text-center" ] [ + saveButton + a [ _href (relUrl app "admin/categories"); _class "btn btn-sm btn-secondary ms-3" ] [ raw "Cancel" ] + ] + ] + ] + ] + |> List.singleton + + +/// Category list page +let categoryList app = [ + let catCol = "col-12 col-md-6 col-xl-5 col-xxl-4" + let descCol = "col-12 col-md-6 col-xl-7 col-xxl-8" + let categoryDetail (cat: DisplayCategory) = + div [ _class "row mwl-table-detail"; _id $"cat_{cat.Id}" ] [ + div [ _class $"{catCol} no-wrap" ] [ + if cat.ParentNames.Length > 0 then + cat.ParentNames + |> Seq.ofArray + |> Seq.map (fun it -> raw $"{it} ⟩ ") + |> List.ofSeq + |> small [ _class "text-muted" ] + raw cat.Name; br [] + small [] [ + let catUrl = relUrl app $"admin/category/{cat.Id}" + if cat.PostCount > 0 then + a [ _href (relUrl app $"category/{cat.Slug}"); _target "_blank" ] [ + raw $"View { cat.PostCount} Post"; if cat.PostCount <> 1 then raw "s" + ]; actionSpacer + a [ _href $"{catUrl}/edit"; _hxTarget $"#cat_{cat.Id}" + _hxSwap $"{HxSwap.InnerHtml} show:#cat_{cat.Id}:top" ] [ + raw "Edit" + ]; actionSpacer + a [ _href catUrl; _hxDelete catUrl; _class "text-danger" + _hxConfirm $"Are you sure you want to delete the category “{cat.Name}”? This action cannot be undone." ] [ + raw "Delete" + ] + ] + ] + div [ _class descCol ] [ + match cat.Description with Some value -> raw value | None -> em [ _class "text-muted" ] [ raw "none" ] + ] + ] + + h2 [ _class "my-3" ] [ raw app.PageTitle ] + article [] [ + a [ _href (relUrl app "admin/category/new/edit"); _class "btn btn-primary btn-sm mb-3"; _hxTarget "#cat_new" ] [ + raw "Add a New Category" + ] + div [ _id "catList"; _class "container" ] [ + if app.Categories.Length = 0 then + div [ _id "cat_new" ] [ + p [ _class "text-muted fst-italic text-center" ] [ + raw "This web log has no categories defined" + ] + ] + else + div [ _class "container" ] [ + div [ _class "row mwl-table-heading" ] [ + div [ _class catCol ] [ raw "Category"; span [ _class "d-md-none" ] [ raw "; Description" ] ] + div [ _class $"{descCol} d-none d-md-inline-block" ] [ raw "Description" ] + ] + ] + form [ _method "post"; _class "container" ] [ + // don't think we need this... + // _hxTarget "#catList"; _hxSwap $"{HxSwap.OuterHtml} show:window:top" + antiCsrf app + div [ _class "row mwl-table-detail"; _id "cat_new" ] [] + yield! app.Categories |> Seq.ofArray |> Seq.map categoryDetail |> List.ofSeq + ] + ] + ] +] + + +/// The main dashboard +let dashboard (model: DashboardModel) app = [ + h2 [ _class "my-3" ] [ txt app.WebLog.Name; raw " • Dashboard" ] + article [ _class "container" ] [ + div [ _class "row" ] [ + section [ _class "col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3" ] [ + div [ _class "card" ] [ + header [ _class "card-header text-white bg-primary" ] [ raw "Posts" ] + div [ _class "card-body" ] [ + h6 [ _class "card-subtitle text-muted pb-3" ] [ + raw "Published " + span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.Posts) ] + raw "  Drafts " + span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.Drafts) ] + ] + if app.IsAuthor then + a [ _href (relUrl app "admin/posts"); _class "btn btn-secondary me-2" ] [ raw "View All" ] + a [ _href (relUrl app "admin/post/new/edit"); _class "btn btn-primary" ] [ + raw "Write a New Post" + ] + ] + ] + ] + section [ _class "col-lg-5 col-xl-4 pb-3" ] [ + div [ _class "card" ] [ + header [ _class "card-header text-white bg-primary" ] [ raw "Pages" ] + div [ _class "card-body" ] [ + h6 [ _class "card-subtitle text-muted pb-3" ] [ + raw "All " + span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.Pages) ] + raw "  Shown in Page List " + span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.ListedPages) ] + ] + if app.IsAuthor then + a [ _href (relUrl app "admin/pages"); _class "btn btn-secondary me-2" ] [ raw "View All" ] + a [ _href (relUrl app "admin/page/new/edit"); _class "btn btn-primary" ] [ + raw "Create a New Page" + ] + ] + ] + ] + ] + div [ _class "row" ] [ + section [ _class "col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3" ] [ + div [ _class "card" ] [ + header [ _class "card-header text-white bg-secondary" ] [ raw "Categories" ] + div [ _class "card-body" ] [ + h6 [ _class "card-subtitle text-muted pb-3"] [ + raw "All " + span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.Categories) ] + raw "  Top Level " + span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.TopLevelCategories) ] + ] + if app.IsWebLogAdmin then + a [ _href (relUrl app "admin/categories"); _class "btn btn-secondary me-2" ] [ + raw "View All" + ] + a [ _href (relUrl app "admin/category/new/edit"); _class "btn btn-secondary" ] [ + raw "Add a New Category" + ] + ] + ] + ] + ] + if app.IsWebLogAdmin then + div [ _class "row pb-3" ] [ + div [ _class "col text-end" ] [ + a [ _href (relUrl app "admin/settings"); _class "btn btn-secondary" ] [ raw "Modify Settings" ] + ] + ] + ] +] + + +/// Custom RSS feed edit form +let feedEdit (model: EditCustomFeedModel) (ratings: MetaItem list) (mediums: MetaItem list) app = [ + h2 [ _class "my-3" ] [ raw app.PageTitle ] + article [] [ + form [ _action (relUrl app "admin/settings/rss/save"); _method "post"; _class "container" ] [ + antiCsrf app + input [ _type "hidden"; _name "Id"; _value model.Id ] + div [ _class "row pb-3" ] [ + div [ _class "col" ] [ + a [ _href (relUrl app "admin/settings#rss-settings") ] [ raw "« Back to Settings" ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col-12 col-lg-6" ] [ + fieldset [ _class "container pb-0" ] [ + legend [] [ raw "Identification" ] + div [ _class "row" ] [ + div [ _class "col" ] [ + textField [ _required ] (nameof model.Path) "Relative Feed Path" model.Path [ + span [ _class "form-text fst-italic" ] [ raw "Appended to "; txt app.WebLog.UrlBase ] + ] + ] + ] + div [ _class "row" ] [ + div [ _class "col py-3 d-flex align-self-center justify-content-center" ] [ + checkboxSwitch [ _onclick "Admin.checkPodcast()"; if model.IsPodcast then _checked ] + (nameof model.IsPodcast) "This Is a Podcast Feed" model.IsPodcast [] + ] + ] + ] + ] + div [ _class "col-12 col-lg-6" ] [ + fieldset [ _class "container pb-0" ] [ + legend [] [ raw "Feed Source" ] + div [ _class "row d-flex align-items-center" ] [ + div [ _class "col-1 d-flex justify-content-end pb-3" ] [ + div [ _class "form-check form-check-inline me-0" ] [ + input [ _type "radio"; _name (nameof model.SourceType); _id "SourceTypeCat" + _class "form-check-input"; _value "category" + if model.SourceType <> "tag" then _checked + _onclick "Admin.customFeedBy('category')" ] + label [ _for "SourceTypeCat"; _class "form-check-label d-none" ] [ raw "Category" ] + ] + ] + div [ _class "col-11 pb-3" ] [ + let cats = + app.Categories + |> Seq.ofArray + |> Seq.map (fun c -> + let parents = + c.ParentNames + |> Array.map (fun it -> $"{it} ⟩ ") + |> String.concat "" + { Name = c.Id; Value = $"{parents}{c.Name}" }) + |> Seq.append [ { Name = ""; Value = "– Select Category –" } ] + selectField [ _id "SourceValueCat"; _required + if model.SourceType = "tag" then _disabled ] + (nameof model.SourceValue) "Category" model.SourceValue cats (_.Name) + (_.Value) [] + ] + div [ _class "col-1 d-flex justify-content-end pb-3" ] [ + div [ _class "form-check form-check-inline me-0" ] [ + input [ _type "radio"; _name (nameof model.SourceType); _id "SourceTypeTag" + _class "form-check-input"; _value "tag" + if model.SourceType= "tag" then _checked + _onclick "Admin.customFeedBy('tag')" ] + label [ _for "sourceTypeTag"; _class "form-check-label d-none" ] [ raw "Tag" ] + ] + ] + div [ _class "col-11 pb-3" ] [ + textField [ _id "SourceValueTag"; _required + if model.SourceType <> "tag" then _disabled ] + (nameof model.SourceValue) "Tag" + (if model.SourceType = "tag" then model.SourceValue else "") [] + ] + ] + ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col" ] [ + fieldset [ _class "container"; _id "podcastFields"; if not model.IsPodcast then _disabled ] [ + legend [] [ raw "Podcast Settings" ] + div [ _class "row" ] [ + div [ _class "col-12 col-md-5 col-lg-4 offset-lg-1 pb-3" ] [ + textField [ _required ] (nameof model.Title) "Title" model.Title [] + ] + div [ _class "col-12 col-md-4 col-lg-4 pb-3" ] [ + textField [] (nameof model.Subtitle) "Podcast Subtitle" model.Subtitle [] + ] + div [ _class "col-12 col-md-3 col-lg-2 pb-3" ] [ + numberField [ _required ] (nameof model.ItemsInFeed) "# Episodes" model.ItemsInFeed [] + ] + ] + div [ _class "row" ] [ + div [ _class "col-12 col-md-5 col-lg-4 offset-lg-1 pb-3" ] [ + textField [ _required ] (nameof model.AppleCategory) "iTunes Category" + model.AppleCategory [ + span [ _class "form-text fst-italic" ] [ + a [ _href "https://www.thepodcasthost.com/planning/itunes-podcast-categories/" + _target "_blank"; _relNoOpener ] [ + raw "iTunes Category / Subcategory List" + ] + ] + ] + ] + div [ _class "col-12 col-md-4 pb-3" ] [ + textField [] (nameof model.AppleSubcategory) "iTunes Subcategory" model.AppleSubcategory + [] + ] + div [ _class "col-12 col-md-3 col-lg-2 pb-3" ] [ + selectField [ _required ] (nameof model.Explicit) "Explicit Rating" model.Explicit + ratings (_.Name) (_.Value) [] + ] + ] + div [ _class "row" ] [ + div [ _class "col-12 col-md-6 col-lg-4 offset-xxl-1 pb-3" ] [ + textField [ _required ] (nameof model.DisplayedAuthor) "Displayed Author" + model.DisplayedAuthor [] + ] + div [ _class "col-12 col-md-6 col-lg-4 pb-3" ] [ + emailField [ _required ] (nameof model.Email) "Author E-mail" model.Email [ + span [ _class "form-text fst-italic" ] [ + raw "For iTunes, must match registered e-mail" + ] + ] + ] + div [ _class "col-12 col-sm-5 col-md-4 col-lg-4 col-xl-3 offset-xl-1 col-xxl-2 offset-xxl-0 pb-3" ] [ + textField [] (nameof model.DefaultMediaType) "Default Media Type" + model.DefaultMediaType [ + span [ _class "form-text fst-italic" ] [ raw "Optional; blank for no default" ] + ] + ] + div [ _class "col-12 col-sm-7 col-md-8 col-lg-10 offset-lg-1 pb-3" ] [ + textField [ _required ] (nameof model.ImageUrl) "Image URL" model.ImageUrl [ + span [ _class "form-text fst-italic"] [ + raw "Relative URL will be appended to "; txt app.WebLog.UrlBase; raw "/" + ] + ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col-12 col-lg-10 offset-lg-1" ] [ + textField [ _required ] (nameof model.Summary) "Summary" model.Summary [ + span [ _class "form-text fst-italic" ] [ raw "Displayed in podcast directories" ] + ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col-12 col-lg-10 offset-lg-1" ] [ + textField [] (nameof model.MediaBaseUrl) "Media Base URL" model.MediaBaseUrl [ + span [ _class "form-text fst-italic" ] [ + raw "Optional; prepended to episode media file if present" + ] + ] + ] + ] + div [ _class "row" ] [ + div [ _class "col-12 col-lg-5 offset-lg-1 pb-3" ] [ + textField [] (nameof model.FundingUrl) "Funding URL" model.FundingUrl [ + span [ _class "form-text fst-italic" ] [ + raw "Optional; URL describing donation options for this podcast, " + raw "relative URL supported" + ] + ] + ] + div [ _class "col-12 col-lg-5 pb-3" ] [ + textField [ _maxlength "128" ] (nameof model.FundingText) "Funding Text" + model.FundingText [ + span [ _class "form-text fst-italic" ] [ raw "Optional; text for the funding link" ] + ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col-8 col-lg-5 offset-lg-1 pb-3" ] [ + textField [] (nameof model.PodcastGuid) "Podcast GUID" model.PodcastGuid [ + span [ _class "form-text fst-italic" ] [ + raw "Optional; v5 UUID uniquely identifying this podcast; " + raw "once entered, do not change this value (" + a [ _href "https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#guid" + _target "_blank"; _relNoOpener ] [ + raw "documentation" + ]; raw ")" + ] + ] + ] + div [ _class "col-4 col-lg-3 offset-lg-2 pb-3" ] [ + selectField [] (nameof model.Medium) "Medium" model.Medium mediums (_.Name) (_.Value) [ + span [ _class "form-text fst-italic" ] [ + raw "Optional; medium of the podcast content (" + a [ _href "https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#medium" + _target "_blank"; _relNoOpener ] [ + raw "documentation" + ]; raw ")" + ] + ] + ] + ] + ] + ] + ] + div [ _class "row pb-3" ] [ div [ _class "col text-center" ] [ saveButton ] ] + ] + ] +] + + +/// Redirect Rule edit form +let redirectEdit (model: EditRedirectRuleModel) app = [ + let url = relUrl app $"admin/settings/redirect-rules/{model.RuleId}" + h3 [] [ raw (if model.RuleId < 0 then "Add" else "Edit"); raw " Redirect Rule" ] + form [ _action url; _hxPost url; _hxTarget "body"; _method "post"; _class "container" ] [ + antiCsrf app + input [ _type "hidden"; _name "RuleId"; _value (string model.RuleId) ] + div [ _class "row" ] [ + div [ _class "col-12 col-lg-5 mb-3" ] [ + textField [ _autofocus; _required ] (nameof model.From) "From" model.From [ + span [ _class "form-text" ] [ raw "From local URL/pattern" ] + ] + ] + div [ _class "col-12 col-lg-5 mb-3" ] [ + textField [ _required ] (nameof model.To) "To" model.To [ + span [ _class "form-text" ] [ raw "To URL/pattern" ] + ] + ] + div [ _class "col-12 col-lg-2 mb-3" ] [ + checkboxSwitch [] (nameof model.IsRegex) "Use RegEx" model.IsRegex [] + ] + ] + if model.RuleId < 0 then + div [ _class "row mb-3" ] [ + div [ _class "col-12 text-center" ] [ + label [ _class "me-1" ] [ raw "Add Rule" ] + div [ _class "btn-group btn-group-sm"; _roleGroup; _ariaLabel "New rule placement button group" ] [ + input [ _type "radio"; _name "InsertAtTop"; _id "at_top"; _class "btn-check"; _value "true" ] + label [ _class "btn btn-sm btn-outline-secondary"; _for "at_top" ] [ raw "Top" ] + input [ _type "radio"; _name "InsertAtTop"; _id "at_bot"; _class "btn-check"; _value "false" + _checked ] + label [ _class "btn btn-sm btn-outline-secondary"; _for "at_bot" ] [ raw "Bottom" ] + ] + ] + ] + div [ _class "row mb-3" ] [ + div [ _class "col text-center" ] [ + saveButton; raw "   " + a [ _href (relUrl app "admin/settings/redirect-rules"); _class "btn btn-sm btn-secondary ms-3" ] [ + raw "Cancel" + ] + ] + ] + ] +] + + +/// The list of current redirect rules +let redirectList (model: RedirectRule list) app = [ + // Generate the detail for a redirect rule + let ruleDetail idx (rule: RedirectRule) = + let ruleId = $"rule_{idx}" + div [ _class "row mwl-table-detail"; _id ruleId ] [ + div [ _class "col-5 no-wrap" ] [ + txt rule.From; br [] + small [] [ + let ruleUrl = relUrl app $"admin/settings/redirect-rules/{idx}" + a [ _href ruleUrl; _hxTarget $"#{ruleId}"; _hxSwap $"{HxSwap.InnerHtml} show:#{ruleId}:top" ] [ + raw "Edit" + ] + if idx > 0 then + actionSpacer; a [ _href $"{ruleUrl}/up"; _hxPost $"{ruleUrl}/up" ] [ raw "Move Up" ] + if idx <> model.Length - 1 then + actionSpacer; a [ _href $"{ruleUrl}/down"; _hxPost $"{ruleUrl}/down" ] [ raw "Move Down" ] + actionSpacer + a [ _class "text-danger"; _href ruleUrl; _hxDelete ruleUrl + _hxConfirm "Are you sure you want to delete this redirect rule?" ] [ + raw "Delete" + ] + ] + ] + div [ _class "col-5" ] [ txt rule.To ] + div [ _class "col-2 text-center" ] [ yesOrNo rule.IsRegex ] + ] + h2 [ _class "my-3" ] [ raw app.PageTitle ] + article [] [ + p [ _class "mb-3" ] [ + a [ _href (relUrl app "admin/settings") ] [ raw "« Back to Settings" ] + ] + div [ _class "container" ] [ + p [] [ + a [ _href (relUrl app "admin/settings/redirect-rules/-1"); _class "btn btn-primary btn-sm mb-3" + _hxTarget "#rule_new" ] [ + raw "Add Redirect Rule" + ] + ] + if List.isEmpty model then + div [ _id "rule_new" ] [ + p [ _class "text-muted text-center fst-italic" ] [ + raw "This web log has no redirect rules defined" + ] + ] + else + div [ _class "container g-0" ] [ + div [ _class "row mwl-table-heading" ] [ + div [ _class "col-5" ] [ raw "From" ] + div [ _class "col-5" ] [ raw "To" ] + div [ _class "col-2 text-center" ] [ raw "RegEx?" ] + ] + ] + div [ _class "row mwl-table-detail"; _id "rule_new" ] [] + form [ _method "post"; _class "container g-0"; _hxTarget "body" ] [ + antiCsrf app; yield! List.mapi ruleDetail model + ] + ] + p [ _class "mt-3 text-muted fst-italic text-center" ] [ + raw "This is an advanced feature; please " + a [ _href "https://bitbadger.solutions/open-source/myweblog/advanced.html#redirect-rules" + _target "_blank" ] [ + raw "read and understand the documentation on this feature" + ] + raw " before adding rules." + ] + ] +] + + +/// Edit a tag mapping +let tagMapEdit (model: EditTagMapModel) app = [ + h5 [ _class "my-3" ] [ txt app.PageTitle ] + form [ _hxPost (relUrl app "admin/settings/tag-mapping/save"); _method "post"; _class "container" + _hxTarget "#tagList"; _hxSwap $"{HxSwap.OuterHtml} show:window:top" ] [ + antiCsrf app + input [ _type "hidden"; _name "Id"; _value model.Id ] + div [ _class "row mb-3" ] [ + div [ _class "col-6 col-lg-4 offset-lg-2" ] [ + textField [ _autofocus; _required ] (nameof model.Tag) "Tag" model.Tag [] + ] + div [ _class "col-6 col-lg-4" ] [ + textField [ _required ] (nameof model.UrlValue) "URL Value" model.UrlValue [] + ] + ] + div [ _class "row mb-3" ] [ + div [ _class "col text-center" ] [ + saveButton; raw "   " + a [ _href (relUrl app "admin/settings/tag-mappings"); _class "btn btn-sm btn-secondary ms-3" ] [ + raw "Cancel" + ] + ] + ] + ] +] + + +/// Display a list of the web log's current tag mappings +let tagMapList (model: TagMap list) app = + let tagMapDetail (map: TagMap) = + let url = relUrl app $"admin/settings/tag-mapping/{map.Id}" + div [ _class "row mwl-table-detail"; _id $"tag_{map.Id}" ] [ + div [ _class "col no-wrap" ] [ + txt map.Tag; br [] + small [] [ + a [ _href $"{url}/edit"; _hxTarget $"#tag_{map.Id}" + _hxSwap $"{HxSwap.InnerHtml} show:#tag_{map.Id}:top" ] [ + raw "Edit" + ]; actionSpacer + a [ _href url; _hxDelete url; _class "text-danger" + _hxConfirm $"Are you sure you want to delete the mapping for “{map.Tag}”? This action cannot be undone." ] [ + raw "Delete" + ] + ] + ] + div [ _class "col" ] [ txt map.UrlValue ] + ] + div [ _id "tagList"; _class "container" ] [ + if List.isEmpty model then + div [ _id "tag_new" ] [ + p [ _class "text-muted text-center fst-italic" ] [ raw "This web log has no tag mappings" ] + ] + else + div [ _class "container g-0" ] [ + div [ _class "row mwl-table-heading" ] [ + div [ _class "col" ] [ raw "Tag" ] + div [ _class "col" ] [ raw "URL Value" ] + ] + ] + form [ _method "post"; _class "container g-0"; _hxTarget "#tagList"; _hxSwap HxSwap.OuterHtml ] [ + antiCsrf app + div [ _class "row mwl-table-detail"; _id "tag_new" ] [] + yield! List.map tagMapDetail model + ] + ] + |> List.singleton + + +/// The list of uploaded files for a web log +let uploadList (model: DisplayUpload seq) app = [ + let webLogBase = $"upload/{app.WebLog.Slug}/" + let relativeBase = relUrl app $"upload/{app.WebLog.Slug}/" + let absoluteBase = app.WebLog.AbsoluteUrl(Permalink webLogBase) + let uploadDetail (upload: DisplayUpload) = + div [ _class "row mwl-table-detail" ] [ + div [ _class "col-6" ] [ + let badgeClass = if upload.Source = string Disk then "secondary" else "primary" + let pathAndName = $"{upload.Path}{upload.Name}" + span [ _class $"badge bg-{badgeClass} text-uppercase float-end mt-1" ] [ raw upload.Source ] + raw upload.Name; br [] + small [] [ + a [ _href $"{relativeBase}{pathAndName}"; _target "_blank" ] [ raw "View File" ] + actionSpacer; span [ _class "text-muted" ] [ raw "Copy " ] + a [ _href $"{absoluteBase}{pathAndName}"; _hxNoBoost + _onclick $"return Admin.copyText('{absoluteBase}{pathAndName}', this)" ] [ + raw "Absolute" + ] + span [ _class "text-muted" ] [ raw " | " ] + a [ _href $"{relativeBase}{pathAndName}"; _hxNoBoost + _onclick $"return Admin.copyText('{relativeBase}{pathAndName}', this)" ] [ + raw "Relative" + ] + if app.WebLog.ExtraPath <> "" then + span [ _class "text-muted" ] [ raw " | " ] + a [ _href $"{webLogBase}{pathAndName}"; _hxNoBoost + _onclick $"return Admin.copyText('/{webLogBase}{pathAndName}', this)" ] [ + raw "For Post" + ] + span [ _class "text-muted" ] [ raw " Link" ] + if app.IsWebLogAdmin then + actionSpacer + let deleteUrl = + if upload.Source = string "Disk" then $"admin/upload/disk/{pathAndName}" + else $"admin/upload/{upload.Id}" + |> relUrl app + a [ _href deleteUrl; _hxDelete deleteUrl; _class "text-danger" + _hxConfirm $"Are you sure you want to delete {upload.Name}? This action cannot be undone." ] [ + raw "Delete" + ] + ] + ] + div [ _class "col-3" ] [ raw upload.Path ] + div [ _class "col-3" ] [ + raw (match upload.UpdatedOn with Some updated -> updated.ToString "yyyy-MM-dd/HH:mm" | None -> "--") + ] + ] + + h2 [ _class "my-3" ] [ raw app.PageTitle ] + article [] [ + a [ _href (relUrl app "admin/upload/new"); _class "btn btn-primary btn-sm mb-3" ] [ raw "Upload a New File" ] + form [ _method "post"; _class "container"; _hxTarget "body" ] [ + antiCsrf app + div [ _class "row" ] [ + div [ _class "col text-center" ] [ + em [ _class "text-muted" ] [ raw "Uploaded files served from" ]; br []; raw relativeBase + ] + ] + if Seq.isEmpty model then + div [ _class "row" ] [ + div [ _class "col text-muted fst-italic text-center" ] [ + br []; raw "This web log has uploaded files" + ] + ] + else + div [ _class "row mwl-table-heading" ] [ + div [ _class "col-6" ] [ raw "File Name" ] + div [ _class "col-3" ] [ raw "Path" ] + div [ _class "col-3" ] [ raw "File Date/Time" ] + ] + yield! model |> Seq.map uploadDetail + ] + ] +] + + +/// Form to upload a new file +let uploadNew app = [ + h2 [ _class "my-3" ] [ raw app.PageTitle ] + article [] [ + form [ _action (relUrl app "admin/upload/save"); _method "post"; _class "container" + _enctype "multipart/form-data"; _hxNoBoost ] [ + antiCsrf app + div [ _class "row" ] [ + div [ _class "col-12 col-md-6 pb-3" ] [ + div [ _class "form-floating" ] [ + input [ _type "file"; _id "file"; _name "File"; _class "form-control"; _placeholder "File" + _required ] + label [ _for "file" ] [ raw "File to Upload" ] + ] + ] + div [ _class "col-12 col-md-6 pb-3 d-flex align-self-center justify-content-around" ] [ + raw "Destination"; br [] + div [ _class "btn-group"; _roleGroup; _ariaLabel "Upload destination button group" ] [ + input [ _type "radio"; _name "Destination"; _id "destination_db"; _class "btn-check" + _value (string Database); if app.WebLog.Uploads = Database then _checked ] + label [ _class "btn btn-outline-primary"; _for "destination_db" ] [ raw (string Database) ] + input [ _type "radio"; _name "Destination"; _id "destination_disk"; _class "btn-check" + _value (string Disk); if app.WebLog.Uploads= Disk then _checked ] + label [ _class "btn btn-outline-secondary"; _for "destination_disk" ] [ raw "Disk" ] + ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col text-center" ] [ + button [ _type "submit"; _class "btn btn-primary" ] [ raw "Upload File" ] + ] + ] + ] + ] +] + + +/// Web log settings page +let webLogSettings + (model: SettingsModel) (themes: Theme list) (pages: Page list) (uploads: UploadDestination list) + (rss: EditRssModel) (feeds: DisplayCustomFeed list) app = [ + h2 [ _class "my-3" ] [ txt app.WebLog.Name; raw " Settings" ] + article [] [ + p [ _class "text-muted" ] [ + raw "Go to: "; a [ _href "#users" ] [ raw "Users" ]; raw " • " + a [ _href "#rss-settings" ] [ raw "RSS Settings" ]; raw " • " + a [ _href "#tag-mappings" ] [ raw "Tag Mappings" ]; raw " • " + a [ _href (relUrl app "admin/settings/redirect-rules") ] [ raw "Redirect Rules" ] + ] + fieldset [ _class "container mb-3" ] [ + legend [] [ raw "Web Log Settings" ] + form [ _action (relUrl app "admin/settings"); _method "post" ] [ + antiCsrf app + div [ _class "container g-0" ] [ + div [ _class "row" ] [ + div [ _class "col-12 col-md-6 col-xl-4 pb-3" ] [ + textField [ _required; _autofocus ] (nameof model.Name) "Name" model.Name [] + ] + div [ _class "col-12 col-md-6 col-xl-4 pb-3" ] [ + textField [ _required ] (nameof model.Slug) "Slug" model.Slug [ + span [ _class "form-text" ] [ + span [ _class "badge rounded-pill bg-warning text-dark" ] [ raw "WARNING" ] + raw " changing this value may break links (" + a [ _href "https://bitbadger.solutions/open-source/myweblog/configuring.html#blog-settings" + _target "_blank" ] [ + raw "more" + ]; raw ")" + ] + ] + ] + div [ _class "col-12 col-md-6 col-xl-4 pb-3" ] [ + textField [] (nameof model.Subtitle) "Subtitle" model.Subtitle [] + ] + div [ _class "col-12 col-md-6 col-xl-4 offset-xl-1 pb-3" ] [ + selectField [ _required ] (nameof model.ThemeId) "Theme" model.ThemeId themes + (fun t -> string t.Id) (fun t -> $"{t.Name} (v{t.Version})") [] + ] + div [ _class "col-12 col-md-6 offset-md-1 col-xl-4 offset-xl-0 pb-3" ] [ + selectField [ _required ] (nameof model.DefaultPage) "Default Page" model.DefaultPage pages + (fun p -> string p.Id) (_.Title) [] + ] + div [ _class "col-12 col-md-4 col-xl-2 pb-3" ] [ + numberField [ _required; _min "0"; _max "50" ] (nameof model.PostsPerPage) "Posts per Page" + model.PostsPerPage [] + ] + ] + div [ _class "row" ] [ + div [ _class "col-12 col-md-4 col-xl-3 offset-xl-2 pb-3" ] [ + textField [ _required ] (nameof model.TimeZone) "Time Zone" model.TimeZone [] + ] + div [ _class "col-12 col-md-4 col-xl-2" ] [ + checkboxSwitch [] (nameof model.AutoHtmx) "Auto-Load htmx" model.AutoHtmx [] + span [ _class "form-text fst-italic" ] [ + a [ _href "https://htmx.org"; _target "_blank"; _relNoOpener ] [ raw "What is this?" ] + ] + ] + div [ _class "col-12 col-md-4 col-xl-3 pb-3" ] [ + selectField [] (nameof model.Uploads) "Default Upload Destination" model.Uploads uploads + string string [] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col text-center" ] [ + button [ _type "submit"; _class "btn btn-primary" ] [ raw "Save Changes" ] + ] + ] + ] + ] + ] + fieldset [ _id "users"; _class "container mb-3 pb-0" ] [ + legend [] [ raw "Users" ] + span [ _hxGet (relUrl app "admin/settings/users"); _hxTrigger HxTrigger.Load; _hxSwap HxSwap.OuterHtml ] [] + ] + fieldset [ _id "rss-settings"; _class "container mb-3 pb-0" ] [ + legend [] [ raw "RSS Settings" ] + form [ _action (relUrl app "admin/settings/rss"); _method "post"; _class "container g-0" ] [ + antiCsrf app + div [ _class "row pb-3" ] [ + div [ _class "col col-xl-8 offset-xl-2" ] [ + fieldset [ _class "d-flex justify-content-evenly flex-row" ] [ + legend [] [ raw "Feeds Enabled" ] + checkboxSwitch [] (nameof rss.IsFeedEnabled) "All Posts" rss.IsFeedEnabled [] + checkboxSwitch [] (nameof rss.IsCategoryEnabled) "Posts by Category" rss.IsCategoryEnabled + [] + checkboxSwitch [] (nameof rss.IsTagEnabled) "Posts by Tag" rss.IsTagEnabled [] + ] + ] + ] + div [ _class "row" ] [ + div [ _class "col-12 col-sm-6 col-md-3 col-xl-2 offset-xl-2 pb-3" ] [ + textField [] (nameof rss.FeedName) "Feed File Name" rss.FeedName [ + span [ _class "form-text" ] [ raw "Default is "; code [] [ raw "feed.xml" ] ] + ] + ] + div [ _class "col-12 col-sm-6 col-md-4 col-xl-2 pb-3" ] [ + numberField [ _required; _min "0" ] (nameof rss.ItemsInFeed) "Items in Feed" rss.ItemsInFeed [ + span [ _class "form-text" ] [ + raw "Set to “0” to use “Posts per Page” setting (" + raw (string app.WebLog.PostsPerPage); raw ")" + ] + ] + ] + div [ _class "col-12 col-md-5 col-xl-4 pb-3" ] [ + textField [] (nameof rss.Copyright) "Copyright String" rss.Copyright [ + span [ _class "form-text" ] [ + raw "Can be a " + a [ _href "https://creativecommons.org/share-your-work/"; _target "_blank" + _relNoOpener ] [ + raw "Creative Commons license string" + ] + ] + ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col text-center" ] [ + button [ _type "submit"; _class "btn btn-primary" ] [ raw "Save Changes" ] + ] + ] + ] + fieldset [ _class "container mb-3 pb-0" ] [ + legend [] [ raw "Custom Feeds" ] + a [ _class "btn btn-sm btn-secondary"; _href (relUrl app "admin/settings/rss/new/edit") ] [ + raw "Add a New Custom Feed" + ] + if feeds.Length = 0 then + p [ _class "text-muted fst-italic text-center" ] [ raw "No custom feeds defined" ] + else + form [ _method "post"; _class "container g-0"; _hxTarget "body" ] [ + antiCsrf app + div [ _class "row mwl-table-heading" ] [ + div [ _class "col-12 col-md-6" ] [ + span [ _class "d-md-none" ] [ raw "Feed" ] + span [ _class "d-none d-md-inline" ] [ raw "Source" ] + ] + div [ _class $"col-12 col-md-6 d-none d-md-inline-block" ] [ raw "Relative Path" ] + ] + for feed in feeds do + div [ _class "row mwl-table-detail" ] [ + div [ _class "col-12 col-md-6" ] [ + txt feed.Source + if feed.IsPodcast then + raw "   "; span [ _class "badge bg-primary" ] [ raw "PODCAST" ] + br [] + small [] [ + let feedUrl = relUrl app $"admin/settings/rss/{feed.Id}" + a [ _href (relUrl app feed.Path); _target "_blank" ] [ raw "View Feed" ] + actionSpacer + a [ _href $"{feedUrl}/edit" ] [ raw "Edit" ]; actionSpacer + a [ _href feedUrl; _hxDelete feedUrl; _class "text-danger" + _hxConfirm $"Are you sure you want to delete the custom RSS feed based on {feed.Source}? This action cannot be undone." ] [ + raw "Delete" + ] + ] + ] + div [ _class "col-12 col-md-6" ] [ + small [ _class "d-md-none" ] [ raw "Served at "; txt feed.Path ] + span [ _class "d-none d-md-inline" ] [ txt feed.Path ] + ] + ] + ] + ] + ] + fieldset [ _id "tag-mappings"; _class "container mb-3 pb-0" ] [ + legend [] [ raw "Tag Mappings" ] + a [ _href (relUrl app "admin/settings/tag-mapping/new/edit"); _class "btn btn-primary btn-sm mb-3" + _hxTarget "#tag_new" ] [ + raw "Add a New Tag Mapping" + ] + span [ _hxGet (relUrl app "admin/settings/tag-mappings"); _hxTrigger HxTrigger.Load + _hxSwap HxSwap.OuterHtml ] [] + ] + ] +] diff --git a/src/admin-theme/admin-dashboard.liquid b/src/admin-theme/admin-dashboard.liquid deleted file mode 100644 index 4d8ac30..0000000 --- a/src/admin-theme/admin-dashboard.liquid +++ /dev/null @@ -1,92 +0,0 @@ -

    {{ page_title }}

    -
    -
    - Themes - -
    -
    - {%- assign cache_base_url = "admin/cache/" -%} - Caches -
    -
    -

    - myWebLog uses a few caches to ensure that it serves pages as fast as possible. - (more information) -

    -
    -
    -
    -
    -
    Web Logs
    -
    -
    - These caches include the page list and categories for each web log -
    - {%- assign web_log_base_url = cache_base_url | append: "web-log/" -%} -
    - - -
    -
    Web Log
    -
    - {%- for web_log in web_logs %} -
    -
    - {{ web_log[1] }}
    - - {{ web_log[2] }}
    - {%- assign refresh_url = web_log_base_url | append: web_log[0] | append: "/refresh" | relative_link -%} - Refresh -
    -
    -
    - {%- endfor %} -
    -
    -
    -
    -
    -
    -
    Themes
    -
    -
    - The theme template cache is filled on demand as pages are displayed; refreshing a theme with no cached - templates will still refresh its asset cache -
    - {%- assign theme_base_url = cache_base_url | append: "theme/" -%} -
    - - -
    -
    Theme
    -
    Cached
    -
    - {%- for theme in cached_themes %} - {% unless theme[0] == "admin" %} -
    -
    - {{ theme[1] }}
    - - {{ theme[0] }} • - {%- assign refresh_url = theme_base_url | append: theme[0] | append: "/refresh" | relative_link -%} - Refresh - -
    -
    {{ theme[2] }}
    -
    - {% endunless %} - {%- endfor %} -
    -
    -
    -
    -
    -
    -
    diff --git a/src/admin-theme/category-edit.liquid b/src/admin-theme/category-edit.liquid deleted file mode 100644 index 3cb44ef..0000000 --- a/src/admin-theme/category-edit.liquid +++ /dev/null @@ -1,52 +0,0 @@ -
    -
    {{ page_title }}
    -
    - - -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    -
    - - Cancel -
    -
    -
    -
    diff --git a/src/admin-theme/category-list-body.liquid b/src/admin-theme/category-list-body.liquid deleted file mode 100644 index 83984a9..0000000 --- a/src/admin-theme/category-list-body.liquid +++ /dev/null @@ -1,57 +0,0 @@ -
    -
    -
    - {%- 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" -%} -
    -
    -
    Category; Description
    -
    Description
    -
    -
    -
    - -
    - {% for cat in categories -%} -
    -
    - {%- if cat.parent_names %} - {% for name in cat.parent_names %}{{ name }} ⟩ {% endfor %} - {%- endif %} - {{ cat.name }}
    - - {%- assign cat_url_base = "admin/category/" | append: cat.id -%} - {%- if cat.post_count > 0 %} - - View {{ cat.post_count }} Post{% unless cat.post_count == 1 %}s{% endunless -%} - - - {%- endif %} - - Edit - - - {%- assign cat_del_link = cat_url_base | append: "/delete" | relative_link -%} - - Delete - - -
    -
    - {%- if cat.description %}{{ cat.description.value }}{% else %}none{% endif %} -
    -
    - {%- endfor %} -
    - {%- else -%} -
    -

    This web log has no categories defined

    -
    - {%- endif %} -
    -
    -
    diff --git a/src/admin-theme/category-list.liquid b/src/admin-theme/category-list.liquid deleted file mode 100644 index 689c41c..0000000 --- a/src/admin-theme/category-list.liquid +++ /dev/null @@ -1,8 +0,0 @@ -

    {{ page_title }}

    - diff --git a/src/admin-theme/upload-list.liquid b/src/admin-theme/upload-list.liquid deleted file mode 100644 index 24c5a9d..0000000 --- a/src/admin-theme/upload-list.liquid +++ /dev/null @@ -1,75 +0,0 @@ -

    {{ page_title }}

    -
    - {%- capture base_url %}{{ "" | relative_link }}{% endcapture -%} - {%- capture upload_path %}upload/{{ web_log.slug }}/{% endcapture -%} - {%- capture upload_base %}{{ base_url }}{{ upload_path }}{% endcapture -%} - Upload a New File -
    - -
    -
    Uploaded files served from
    {{ upload_base }}
    -
    - {%- assign file_count = files | size -%} - {%- if file_count > 0 %} -
    -
    File Name
    -
    Path
    -
    File Date/Time
    -
    - {% for file in files %} -
    -
    - {%- capture badge_class -%} - {%- if file.source == "Disk" %}secondary{% else %}primary{% endif -%} - {%- endcapture -%} - {%- assign path_and_name = file.path | append: file.name -%} - {%- assign blog_rel = upload_path | append: path_and_name -%} - {{ file.source }} - {{ file.name }}
    - - View File - • Copy - - Absolute - - | - - Relative - - {%- unless base_url == "/" %} - | - - For Post - - {%- endunless %} - Link - {% if is_web_log_admin %} - - {%- capture delete_url -%} - {%- if file.source == "Disk" -%} - admin/upload/delete/{{ path_and_name }} - {%- else -%} - admin/upload/{{ file.id }}/delete - {%- endif -%} - {%- endcapture -%} - Delete - {% endif %} - -
    -
    {{ file.path }}
    -
    - {% if file.updated_on %}{{ file.updated_on.value | date: "yyyy-MM-dd/HH:mm" }}{% else %}--{% endif %} -
    -
    - {% endfor %} - {%- else -%} -
    -

    This web log has uploaded files
    -
    - {%- endif %} -
    -
    diff --git a/src/admin-theme/upload-new.liquid b/src/admin-theme/upload-new.liquid deleted file mode 100644 index fcd9e8d..0000000 --- a/src/admin-theme/upload-new.liquid +++ /dev/null @@ -1,29 +0,0 @@ -

    {{ page_title }}

    -
    -
    - -
    -
    -
    - - -
    -
    -
    - Destination
    -
    - - - - -
    -
    -
    -
    -
    -
    -
    -
    -- 2.45.1 From ddab491dfc886c62279680b558edbcc08eb83ade Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Fri, 15 Mar 2024 18:03:38 -0400 Subject: [PATCH 100/123] First cut of common edit model --- src/MyWebLog.Domain/ViewModels.fs | 53 +++++++++++++++++++++++++++++++ src/MyWebLog/DotLiquidBespoke.fs | 7 ++-- src/MyWebLog/Views/Helpers.fs | 34 ++++++++++++++++++++ 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index 1e802aa..3632963 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -604,6 +604,59 @@ type EditMyInfoModel = { NewPasswordConfirm = "" } +/// View model common to page and post edits +type EditCommonModel() = + + /// Find the latest revision within a list of revisions + let findLatestRevision (revs: Revision list) = + match revs |> List.sortByDescending _.AsOf |> List.tryHead with Some rev -> rev | None -> Revision.Empty + + /// The ID of the page or post + member val Id = "" with get, set + + /// The title of the page or post + member val Title = "" with get, set + + /// The permalink for the page or post + member val Permalink = "" with get, set + + /// The entity to which this model applies ("page" or "post") + member val Entity = "" with get, set + + /// Whether to provide a link to manage chapters + member val IncludeChapterLink = false with get, set + + /// The source type ("HTML" or "Markdown") + member val Source = "" with get, set + + /// The text of the page or post + member val Text = "" with get, set + + /// Whether this is a new page or post + member this.IsNew with get () = this.Id = "new" + + /// Fill the properties of this object from a page + member this.FromPage (page: Page) = + let latest = findLatestRevision page.Revisions + this.Id <- string page.Id + this.Title <- page.Title + this.Permalink <- string page.Permalink + this.Entity <- "page" + this.Source <- latest.Text.SourceType + this.Text <- latest.Text.Text + + /// Fill the properties of this object from a post + member this.FromPost (post: Post) = + let latest = findLatestRevision post.Revisions + this.Id <- string post.Id + this.Title <- post.Title + this.Permalink <- string post.Permalink + this.Entity <- "post" + this.IncludeChapterLink <- Option.isSome post.Episode && Option.isSome post.Episode.Value.Chapters + this.Source <- latest.Text.SourceType + this.Text <- latest.Text.Text + + /// View model to edit a page [] type EditPageModel = { diff --git a/src/MyWebLog/DotLiquidBespoke.fs b/src/MyWebLog/DotLiquidBespoke.fs index fe1c9d7..872486b 100644 --- a/src/MyWebLog/DotLiquidBespoke.fs +++ b/src/MyWebLog/DotLiquidBespoke.fs @@ -228,10 +228,9 @@ let register () = typeof; typeof; typeof; typeof; 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; typeof; typeof; typeof + typeof; typeof; typeof; typeof + typeof; typeof; typeof; typeof // Framework types typeof; typeof; typeof; typeof typeof; typeof; typeof; typeof diff --git a/src/MyWebLog/Views/Helpers.fs b/src/MyWebLog/Views/Helpers.fs index a087d2c..23213c0 100644 --- a/src/MyWebLog/Views/Helpers.fs +++ b/src/MyWebLog/Views/Helpers.fs @@ -310,6 +310,40 @@ let roundTrip = InstantPattern.CreateWithInvariantCulture "uuuu'-'MM'-'dd'T'HH': let private capitalize (it: string) = $"{(string it[0]).ToUpper()}{it[1..]}" +/// The common edit form shared by pages and posts +let commonEdit (model: EditCommonModel) app = [ + textField [ _required; _autofocus ] (nameof model.Title) "Title" model.Title [] + textField [ _required ] (nameof model.Permalink) "Permalink" model.Permalink [ + if not model.IsNew then + let urlBase = relUrl app $"admin/{model.Entity}/{model.Id}" + span [ _class "form-text" ] [ + a [ _href $"{urlBase}/permalinks" ] [ raw "Manage Permalinks" ]; actionSpacer + a [ _href $"{urlBase}/revisions" ] [ raw "Manage Revisions" ] + if model.IncludeChapterLink then + span [ _id "chapterEditLink" ] [ + actionSpacer; a [ _href $"{urlBase}/chapters" ] [ raw "Manage Chapters" ] + ] + ] + ] + div [ _class "mb-2" ] [ + label [ _for "text" ] [ raw "Text" ]; raw "     " + div [ _class "btn-group btn-group-sm"; _roleGroup; _ariaLabel "Text format button group" ] [ + input [ _type "radio"; _name (nameof model.Source); _id "source_html"; _class "btn-check" + _value (string Html); if model.Source = string Html then _checked ] + label [ _class "btn btn-sm btn-outline-secondary"; _for "source_html" ] [ raw "HTML" ] + input [ _type "radio"; _name (nameof model.Source); _id "source_md"; _class "btn-check" + _value (string Markdown); if model.Source = string Markdown then _checked ] + label [ _class "btn btn-sm btn-outline-secondary"; _for "source_md" ] [ raw "Markdown" ] + ] + ] + div [ _class "pb-3" ] [ + textarea [ _name (nameof model.Text); _id (nameof model.Text); _class "form-control"; _rows "20" ] [ + raw model.Text + ] + ] +] + + /// Form to manage permalinks for pages or posts let managePermalinks (model: ManagePermalinksModel) app = [ let baseUrl = relUrl app $"admin/{model.Entity}/" -- 2.45.1 From ec04fea86c8e3b379a62664d487c4c105177e7a2 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Fri, 15 Mar 2024 22:49:27 -0400 Subject: [PATCH 101/123] Migrate page edit to GVE --- src/MyWebLog.Domain/ViewModels.fs | 245 +++++++++++------------------- src/MyWebLog/Handlers/Helpers.fs | 10 +- src/MyWebLog/Handlers/Page.fs | 12 +- src/MyWebLog/Handlers/Post.fs | 4 +- src/MyWebLog/Views/Helpers.fs | 83 +++++++--- src/MyWebLog/Views/Page.fs | 21 +++ src/admin-theme/wwwroot/admin.js | 2 +- 7 files changed, 187 insertions(+), 190 deletions(-) diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index 3632963..8c69c2c 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -626,88 +626,65 @@ type EditCommonModel() = /// Whether to provide a link to manage chapters member val IncludeChapterLink = false with get, set + /// The template to use to display the page + member val Template = "" with get, set + /// The source type ("HTML" or "Markdown") member val Source = "" with get, set /// The text of the page or post member val Text = "" with get, set + /// Names of metadata items + member val MetaNames: string array = [||] with get, set + + /// Values of metadata items + member val MetaValues: string array = [||] with get, set + /// Whether this is a new page or post member this.IsNew with get () = this.Id = "new" /// Fill the properties of this object from a page - member this.FromPage (page: Page) = + member this.PopulateFromPage (page: Page) = let latest = findLatestRevision page.Revisions - this.Id <- string page.Id - this.Title <- page.Title - this.Permalink <- string page.Permalink - this.Entity <- "page" - this.Source <- latest.Text.SourceType - this.Text <- latest.Text.Text + this.Id <- string page.Id + this.Title <- page.Title + this.Permalink <- string page.Permalink + this.Entity <- "page" + this.Template <- defaultArg page.Template "" + this.Source <- latest.Text.SourceType + this.Text <- latest.Text.Text + this.MetaNames <- page.Metadata |> List.map _.Name |> Array.ofList + this.MetaValues <- page.Metadata |> List.map _.Value |> Array.ofList /// Fill the properties of this object from a post - member this.FromPost (post: Post) = + member this.PopulateFromPost (post: Post) = let latest = findLatestRevision post.Revisions this.Id <- string post.Id this.Title <- post.Title this.Permalink <- string post.Permalink this.Entity <- "post" this.IncludeChapterLink <- Option.isSome post.Episode && Option.isSome post.Episode.Value.Chapters + this.Template <- defaultArg post.Template "" this.Source <- latest.Text.SourceType this.Text <- latest.Text.Text + this.MetaNames <- post.Metadata |> List.map _.Name |> Array.ofList + this.MetaValues <- post.Metadata |> List.map _.Value |> Array.ofList + - /// 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 +type EditPageModel() = + inherit EditCommonModel() /// Whether this page is shown in the page list - IsShownInPageList: bool + member val IsShownInPageList = false with get, set - /// The source format for the text - Source: string - - /// The text of the page - Text: string - - /// Names of metadata items - MetaNames: string array - - /// Values of metadata items - MetaValues: string array -} with - /// Create an edit model from an existing page - static member FromPage (page: Page) = - let latest = - match page.Revisions |> List.sortByDescending _.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 = string page.Id - Title = page.Title - Permalink = string page.Permalink - Template = defaultArg page.Template "" - IsShownInPageList = page.IsInPageList - Source = latest.Text.SourceType - Text = latest.Text.Text - MetaNames = page.Metadata |> List.map _.Name |> Array.ofList - MetaValues = page.Metadata |> List.map _.Value |> Array.ofList } - - /// Whether this is a new page - member this.IsNew = - this.PageId = "new" + static member FromPage(page: Page) = + let model = EditPageModel() + model.PopulateFromPage page + model.IsShownInPageList <- page.IsInPageList + model /// Update a page with values from this model member this.UpdatePage (page: Page) now = @@ -737,163 +714,123 @@ type EditPageModel = { /// 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 +type EditPostModel() = + inherit EditCommonModel() /// The tags for the post - Tags: string - - /// The template used to display the post - Template: string + member val Tags = "" with get, set /// The category IDs for the post - CategoryIds: string array + member val CategoryIds: string array = [||] with get, set /// The post status - Status: string + member val Status = "" with get, set /// Whether this post should be published - DoPublish: bool - - /// Names of metadata items - MetaNames: string array - - /// Values of metadata items - MetaValues: string array + member val DoPublish = false with get, set /// Whether to override the published date/time - SetPublished: bool + member val SetPublished = false with get, set /// The published date/time to override - PubOverride: Nullable + member val PubOverride = Nullable() with get, set /// Whether all revisions should be purged and the override date set as the updated date as well - SetUpdated: bool + member val SetUpdated = false with get, set /// Whether this post has a podcast episode - IsEpisode: bool + member val IsEpisode = false with get, set /// The URL for the media for this episode (may be permalink) - Media: string + member val Media = "" with get, set /// The size (in bytes) of the media for this episode - Length: int64 + member val Length = 0L with get, set /// The duration of the media for this episode - Duration: string + member val Duration = "" with get, set /// The media type (optional, defaults to podcast-defined media type) - MediaType: string + member val MediaType = "" with get, set /// The URL for the image for this episode (may be permalink; optional, defaults to podcast image) - ImageUrl: string + member val ImageUrl = "" with get, set /// A subtitle for the episode (optional) - Subtitle: string + member val Subtitle = "" with get, set /// The explicit rating for this episode (optional, defaults to podcast setting) - Explicit: string + member val Explicit = "" with get, set /// The chapter source ("internal" for chapters defined here, "external" for a file link, "none" if none defined) - ChapterSource: string + member val ChapterSource = "" with get, set /// The URL for the chapter file for the episode (may be permalink; optional) - ChapterFile: string + member val ChapterFile = "" with get, set /// The type of the chapter file (optional; defaults to application/json+chapters if chapterFile is provided) - ChapterType: string + member val ChapterType = "" with get, set /// Whether the chapter file (or chapters) contains/contain waypoints - ContainsWaypoints: bool + member val ContainsWaypoints = false with get, set /// The URL for the transcript (may be permalink; optional) - TranscriptUrl: string + member val TranscriptUrl = "" with get, set /// The MIME type for the transcript (optional, recommended if transcriptUrl is provided) - TranscriptType: string + member val TranscriptType = "" with get, set /// The language of the transcript (optional) - TranscriptLang: string + member val TranscriptLang = "" with get, set /// Whether the provided transcript should be presented as captions - TranscriptCaptions: bool + member val TranscriptCaptions = false with get, set /// The season number (optional) - SeasonNumber: int + member val SeasonNumber = 0 with get, set /// A description of this season (optional, ignored if season number is not provided) - SeasonDescription: string + member val SeasonDescription = "" with get, set /// The episode number (decimal; optional) - EpisodeNumber: string + member val EpisodeNumber = "" with get, set /// A description of this episode (optional, ignored if episode number is not provided) - EpisodeDescription: string -} with + member val EpisodeDescription = "" with get, set /// Create an edit model from an existing past static member FromPost (webLog: WebLog) (post: Post) = - let latest = - match post.Revisions |> List.sortByDescending _.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 + let model = EditPostModel() + let post = if post.Metadata |> List.isEmpty then { post with Metadata = [ MetaItem.Empty ] } else post + model.PopulateFromPost post let episode = defaultArg post.Episode Episode.Empty - { PostId = string post.Id - Title = post.Title - Permalink = string post.Permalink - Source = latest.Text.SourceType - Text = latest.Text.Text - Tags = String.Join(", ", post.Tags) - Template = defaultArg post.Template "" - CategoryIds = post.CategoryIds |> List.map string |> Array.ofList - Status = string post.Status - DoPublish = false - MetaNames = post.Metadata |> List.map _.Name |> Array.ofList - MetaValues = post.Metadata |> List.map _.Value |> Array.ofList - SetPublished = false - PubOverride = post.PublishedOn |> Option.map webLog.LocalTime |> Option.toNullable - SetUpdated = false - IsEpisode = Option.isSome post.Episode - Media = episode.Media - Length = episode.Length - Duration = defaultArg (episode.FormatDuration()) "" - MediaType = defaultArg episode.MediaType "" - ImageUrl = defaultArg episode.ImageUrl "" - Subtitle = defaultArg episode.Subtitle "" - Explicit = defaultArg (episode.Explicit |> Option.map string) "" - ChapterSource = if Option.isSome episode.Chapters then "internal" - elif Option.isSome episode.ChapterFile then "external" - else "none" - ChapterFile = defaultArg episode.ChapterFile "" - ChapterType = defaultArg episode.ChapterType "" - ContainsWaypoints = defaultArg episode.ChapterWaypoints false - TranscriptUrl = defaultArg episode.TranscriptUrl "" - TranscriptType = defaultArg episode.TranscriptType "" - TranscriptLang = defaultArg episode.TranscriptLang "" - TranscriptCaptions = defaultArg episode.TranscriptCaptions false - SeasonNumber = defaultArg episode.SeasonNumber 0 - SeasonDescription = defaultArg episode.SeasonDescription "" - EpisodeNumber = defaultArg (episode.EpisodeNumber |> Option.map string) "" - EpisodeDescription = defaultArg episode.EpisodeDescription "" } - - /// Whether this is a new post - member this.IsNew = - this.PostId = "new" + model.Tags <- post.Tags |> String.concat ", " + model.CategoryIds <- post.CategoryIds |> List.map string |> Array.ofList + model.Status <- string post.Status + model.PubOverride <- post.PublishedOn |> Option.map webLog.LocalTime |> Option.toNullable + model.IsEpisode <- Option.isSome post.Episode + model.Media <- episode.Media + model.Length <- episode.Length + model.Duration <- defaultArg (episode.FormatDuration()) "" + model.MediaType <- defaultArg episode.MediaType "" + model.ImageUrl <- defaultArg episode.ImageUrl "" + model.Subtitle <- defaultArg episode.Subtitle "" + model.Explicit <- defaultArg (episode.Explicit |> Option.map string) "" + model.ChapterSource <- if Option.isSome episode.Chapters then "internal" + elif Option.isSome episode.ChapterFile then "external" + else "none" + model.ChapterFile <- defaultArg episode.ChapterFile "" + model.ChapterType <- defaultArg episode.ChapterType "" + model.ContainsWaypoints <- defaultArg episode.ChapterWaypoints false + model.TranscriptUrl <- defaultArg episode.TranscriptUrl "" + model.TranscriptType <- defaultArg episode.TranscriptType "" + model.TranscriptLang <- defaultArg episode.TranscriptLang "" + model.TranscriptCaptions <- defaultArg episode.TranscriptCaptions false + model.SeasonNumber <- defaultArg episode.SeasonNumber 0 + model.SeasonDescription <- defaultArg episode.SeasonDescription "" + model.EpisodeNumber <- defaultArg (episode.EpisodeNumber |> Option.map string) "" + model.EpisodeDescription <- defaultArg episode.EpisodeDescription "" + model /// Update a post with values from the submitted form member this.UpdatePost (post: Post) now = diff --git a/src/MyWebLog/Handlers/Helpers.fs b/src/MyWebLog/Handlers/Helpers.fs index e78a580..98c7c9a 100644 --- a/src/MyWebLog/Handlers/Helpers.fs +++ b/src/MyWebLog/Handlers/Helpers.fs @@ -427,23 +427,21 @@ let absoluteUrl (url: string) (ctx: HttpContext) = if url.StartsWith "http" then url else ctx.WebLog.AbsoluteUrl (Permalink url) -open System.Collections.Generic open MyWebLog.Data -/// Get the templates available for the current web log's theme (in a key/value pair list) +/// Get the templates available for the current web log's theme (in a meta item list) let templatesForTheme (ctx: HttpContext) (typ: string) = backgroundTask { match! ctx.Data.Theme.FindByIdWithoutText ctx.WebLog.ThemeId with | Some theme -> return seq { - KeyValuePair.Create("", $"- Default (single-{typ}) -") + { Name = ""; Value = $"- 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)) + |> Seq.map (fun it -> { Name = it.Name; Value = it.Name }) } - |> Array.ofSeq - | None -> return [| KeyValuePair.Create("", $"- Default (single-{typ}) -") |] + | None -> return seq { { Name = ""; Value = $"- Default (single-{typ}) -" } } } /// Get all authors for a list of posts as metadata items diff --git a/src/MyWebLog/Handlers/Page.fs b/src/MyWebLog/Handlers/Page.fs index 9563b94..070976c 100644 --- a/src/MyWebLog/Handlers/Page.fs +++ b/src/MyWebLog/Handlers/Page.fs @@ -34,15 +34,7 @@ let edit pgId : HttpHandler = requireAccess Author >=> fun next ctx -> task { | Some (title, page) when canEdit page.AuthorId ctx -> let model = EditPageModel.FromPage page let! templates = templatesForTheme ctx "page" - return! - hashForPage title - |> withAntiCsrf ctx - |> addToHash ViewContext.Model model - |> addToHash "metadata" ( - Array.zip model.MetaNames model.MetaValues - |> Array.mapi (fun idx (name, value) -> [| string idx; name; value |])) - |> addToHash "templates" templates - |> adminView "page-edit" next ctx + return! adminPage title true next ctx (Views.Page.pageEdit model templates) | Some _ -> return! Error.notAuthorized next ctx | None -> return! Error.notFound next ctx } @@ -177,7 +169,7 @@ let save : HttpHandler = requireAccess Author >=> fun next ctx -> task { AuthorId = ctx.UserId PublishedOn = now } |> someTask - else data.Page.FindFullById (PageId model.PageId) ctx.WebLog.Id + else data.Page.FindFullById (PageId model.Id) ctx.WebLog.Id match! tryPage with | Some page when canEdit page.AuthorId ctx -> let updateList = page.IsInPageList <> model.IsShownInPageList diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index 8e0cdb4..67e44a4 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -505,7 +505,7 @@ let save : HttpHandler = requireAccess Author >=> fun next ctx -> task { WebLogId = ctx.WebLog.Id AuthorId = ctx.UserId } |> someTask - else data.Post.FindFullById (PostId model.PostId) ctx.WebLog.Id + else data.Post.FindFullById (PostId model.Id) ctx.WebLog.Id match! tryPost with | Some post when canEdit post.AuthorId ctx -> let priorCats = post.CategoryIds @@ -522,7 +522,7 @@ let save : HttpHandler = requireAccess Author >=> fun next ctx -> task { Revisions = [ { (List.head post.Revisions) with AsOf = dt } ] } else { post with PublishedOn = Some dt } else post - do! (if model.PostId = "new" then data.Post.Add else data.Post.Update) updatedPost + do! (if model.IsNew then data.Post.Add else data.Post.Update) updatedPost // If the post was published or its categories changed, refresh the category cache if model.DoPublish || not (priorCats diff --git a/src/MyWebLog/Views/Helpers.fs b/src/MyWebLog/Views/Helpers.fs index 23213c0..6ddfdc0 100644 --- a/src/MyWebLog/Views/Helpers.fs +++ b/src/MyWebLog/Views/Helpers.fs @@ -105,19 +105,25 @@ let shortTime app (instant: Instant) = let yesOrNo value = raw (if value then "Yes" else "No") +/// Extract an attribute value from a list of attributes, remove that attribute if it is found +let extractAttrValue name attrs = + let valueAttr = attrs |> List.tryFind (fun x -> match x with KeyValue (key, _) when key = name -> true | _ -> false) + match valueAttr with + | Some (KeyValue (_, value)) -> + Some value, + attrs |> List.filter (fun x -> match x with KeyValue (key, _) when key = name -> false | _ -> true) + | Some _ | None -> None, attrs + /// Create a text input field let inputField fieldType attrs name labelText value extra = - let fieldId, newAttrs = - let passedId = attrs |> List.tryFind (fun x -> match x with KeyValue ("id", _) -> true | _ -> false) - match passedId with - | Some (KeyValue (_, idValue)) -> - idValue, attrs |> List.filter (fun x -> match x with KeyValue ("id", _) -> false | _ -> true) - | Some _ | None -> name, attrs - div [ _class "form-floating" ] [ - [ _type fieldType; _name name; _id fieldId; _class "form-control"; _placeholder labelText; _value value ] - |> List.append newAttrs + let fieldId, attrs = extractAttrValue "id" attrs + let cssClass, attrs = extractAttrValue "class" attrs + div [ _class $"""form-floating {defaultArg cssClass ""}""" ] [ + [ _type fieldType; _name name; _id (defaultArg fieldId name); _class "form-control"; _placeholder labelText + _value value ] + |> List.append attrs |> input - label [ _for fieldId ] [ raw labelText ] + label [ _for (defaultArg fieldId name) ] [ raw labelText ] yield! extra ] @@ -140,7 +146,8 @@ let passwordField attrs name labelText value extra = /// Create a select (dropdown) field let selectField<'T, 'a> attrs name labelText value (values: 'T seq) (idFunc: 'T -> 'a) (displayFunc: 'T -> string) extra = - div [ _class "form-floating" ] [ + let cssClass, attrs = extractAttrValue "class" attrs + div [ _class $"""form-floating {defaultArg cssClass ""}""" ] [ select ([ _name name; _id name; _class "form-control" ] |> List.append attrs) [ for item in values do let itemId = string (idFunc item) @@ -152,7 +159,8 @@ let selectField<'T, 'a> /// Create a checkbox input styled as a switch let checkboxSwitch attrs name labelText (value: bool) extra = - div [ _class "form-check form-switch" ] [ + let cssClass, attrs = extractAttrValue "class" attrs + div [ _class $"""form-check form-switch {defaultArg cssClass ""}""" ] [ [ _type "checkbox"; _name name; _id name; _class "form-check-input"; _value "true"; if value then _checked ] |> List.append attrs |> input @@ -312,8 +320,8 @@ let private capitalize (it: string) = /// The common edit form shared by pages and posts let commonEdit (model: EditCommonModel) app = [ - textField [ _required; _autofocus ] (nameof model.Title) "Title" model.Title [] - textField [ _required ] (nameof model.Permalink) "Permalink" model.Permalink [ + textField [ _class "mb-3"; _required; _autofocus ] (nameof model.Title) "Title" model.Title [] + textField [ _class "mb-3"; _required ] (nameof model.Permalink) "Permalink" model.Permalink [ if not model.IsNew then let urlBase = relUrl app $"admin/{model.Entity}/{model.Id}" span [ _class "form-text" ] [ @@ -329,14 +337,14 @@ let commonEdit (model: EditCommonModel) app = [ label [ _for "text" ] [ raw "Text" ]; raw "     " div [ _class "btn-group btn-group-sm"; _roleGroup; _ariaLabel "Text format button group" ] [ input [ _type "radio"; _name (nameof model.Source); _id "source_html"; _class "btn-check" - _value (string Html); if model.Source = string Html then _checked ] + _value "HTML"; if model.Source = "HTML" then _checked ] label [ _class "btn btn-sm btn-outline-secondary"; _for "source_html" ] [ raw "HTML" ] input [ _type "radio"; _name (nameof model.Source); _id "source_md"; _class "btn-check" - _value (string Markdown); if model.Source = string Markdown then _checked ] + _value "Markdown"; if model.Source = "Markdown" then _checked ] label [ _class "btn btn-sm btn-outline-secondary"; _for "source_md" ] [ raw "Markdown" ] ] ] - div [ _class "pb-3" ] [ + div [ _class "mb-3" ] [ textarea [ _name (nameof model.Text); _id (nameof model.Text); _class "form-control"; _rows "20" ] [ raw model.Text ] @@ -344,6 +352,47 @@ let commonEdit (model: EditCommonModel) app = [ ] +/// Display a common template list +let commonTemplates (model: EditCommonModel) (templates: MetaItem seq) = + selectField [ _class "mb-3" ] (nameof model.Template) $"{capitalize model.Entity} Template" model.Template templates + (_.Name) (_.Value) [] + + +/// Display the metadata item edit form +let commonMetaItems (model: EditCommonModel) = + let items = Array.zip model.MetaNames model.MetaValues + let metaDetail idx (name, value) = + div [ _id $"meta_%i{idx}"; _class "row mb-3" ] [ + div [ _class "col-1 text-center align-self-center" ] [ + button [ _type "button"; _class "btn btn-sm btn-danger"; _onclick $"Admin.removeMetaItem({idx})" ] [ + raw "−" + ] + ] + div [ _class "col-3" ] [ textField [ _id $"MetaNames_{idx}" ] (nameof model.MetaNames) "Name" name [] ] + div [ _class "col-8" ] [ textField [ _id $"MetaValues_{idx}" ] (nameof model.MetaValues) "Value" value [] ] + ] + + fieldset [] [ + legend [] [ + raw "Metadata " + button [ _type "button"; _class "btn btn-sm btn-secondary"; _data "bs-toggle" "collapse" + _data "bs-target" "#meta_item_container" ] [ + raw "show" + ] + ] + div [ _id "meta_item_container"; _class "collapse" ] [ + div [ _id "meta_items"; _class "container" ] (items |> Array.mapi metaDetail |> List.ofArray) + button [ _type "button"; _class "btn btn-sm btn-secondary"; _onclick "Admin.addMetaItem()" ] [ + raw "Add an Item" + ] + script [] [ + raw """document.addEventListener("DOMContentLoaded", """ + raw $"() => Admin.setNextMetaIndex({items.Length}))" + ] + ] + ] + + /// Form to manage permalinks for pages or posts let managePermalinks (model: ManagePermalinksModel) app = [ let baseUrl = relUrl app $"admin/{model.Entity}/" diff --git a/src/MyWebLog/Views/Page.fs b/src/MyWebLog/Views/Page.fs index 360858d..55b28de 100644 --- a/src/MyWebLog/Views/Page.fs +++ b/src/MyWebLog/Views/Page.fs @@ -5,6 +5,27 @@ open Giraffe.ViewEngine.Htmx open MyWebLog open MyWebLog.ViewModels +/// The form to edit pages +let pageEdit (model: EditPageModel) templates app = [ + h2 [ _class "my-3" ] [ raw app.PageTitle ] + article [] [ + form [ _action (relUrl app "admin/page/save"); _method "post"; _hxPushUrl "true"; _class "container" ] [ + antiCsrf app + input [ _type "hidden"; _name (nameof model.Id); _value model.Id ] + div [ _class "row mb-3" ] [ + div [ _class "col-9" ] (commonEdit model app) + div [ _class "col-3" ] [ + commonTemplates model templates + checkboxSwitch [] (nameof model.IsShownInPageList) "Show in Page List" model.IsShownInPageList [] + ] + ] + div [ _class "row mb-3" ] [ div [ _class "col" ] [ saveButton ] ] + div [ _class "row mb-3" ] [ div [ _class "col" ] [ commonMetaItems model ] ] + ] + ] +] + + /// Display a list of pages for this web log let pageList (pages: DisplayPage list) pageNbr hasNext app = [ h2 [ _class "my-3" ] [ raw app.PageTitle ] diff --git a/src/admin-theme/wwwroot/admin.js b/src/admin-theme/wwwroot/admin.js index d69e134..7edc1e5 100644 --- a/src/admin-theme/wwwroot/admin.js +++ b/src/admin-theme/wwwroot/admin.js @@ -146,7 +146,7 @@ this.Admin = { newRow.appendChild(nameCol) newRow.appendChild(valueCol) - document.getElementById("metaItems").appendChild(newRow) + document.getElementById("meta_items").appendChild(newRow) this.nextMetaIndex++ }, -- 2.45.1 From a448339870e3f49ee981e921a07c75ae2ffc6ac8 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 16 Mar 2024 00:06:04 -0400 Subject: [PATCH 102/123] Migrate post edit to GVE --- src/MyWebLog/Handlers/Helpers.fs | 8 - src/MyWebLog/Handlers/Post.fs | 21 +-- src/MyWebLog/Views/Post.fs | 243 +++++++++++++++++++++++++++++++ 3 files changed, 249 insertions(+), 23 deletions(-) diff --git a/src/MyWebLog/Handlers/Helpers.fs b/src/MyWebLog/Handlers/Helpers.fs index 98c7c9a..74cd160 100644 --- a/src/MyWebLog/Handlers/Helpers.fs +++ b/src/MyWebLog/Handlers/Helpers.fs @@ -178,10 +178,6 @@ let addToHash key (value: obj) (hash: Hash) = if hash.ContainsKey key then hash[key] <- value else hash.Add(key, value) hash -/// Add anti-CSRF tokens to the given hash -let withAntiCsrf (ctx: HttpContext) = - addToHash ViewContext.AntiCsrfTokens ctx.CsrfTokenSet - open System.Security.Claims open Giraffe open Giraffe.Htmx @@ -362,10 +358,6 @@ let themedView template next ctx hash = task { /// The ID for the admin theme let adminTheme = ThemeId "admin" -/// Display a view for the admin theme -let adminView template = - viewForTheme adminTheme template - /// Display a bare view for the admin theme let adminBareView template = bareForTheme adminTheme template diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index 67e44a4..6b8fec5 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -272,21 +272,12 @@ let edit postId : HttpHandler = requireAccess Author >=> fun next ctx -> task { | Some (title, post) when canEdit post.AuthorId ctx -> let! templates = templatesForTheme ctx "post" let model = EditPostModel.FromPost ctx.WebLog post - return! - hashForPage title - |> withAntiCsrf ctx - |> addToHash ViewContext.Model model - |> addToHash "metadata" ( - Array.zip model.MetaNames model.MetaValues - |> Array.mapi (fun idx (name, value) -> [| string idx; name; value |])) - |> addToHash "templates" templates - |> addToHash "explicit_values" [| - KeyValuePair.Create("", "– Default –") - KeyValuePair.Create(string Yes, "Yes") - KeyValuePair.Create(string No, "No") - KeyValuePair.Create(string Clean, "Clean") - |] - |> adminView "post-edit" next ctx + let ratings = [ + { Name = string Yes; Value = "Yes" } + { Name = string No; Value = "No" } + { Name = string Clean; Value = "Clean" } + ] + return! adminPage title true next ctx (Views.Post.postEdit model templates ratings) | Some _ -> return! Error.notAuthorized next ctx | None -> return! Error.notFound next ctx } diff --git a/src/MyWebLog/Views/Post.fs b/src/MyWebLog/Views/Post.fs index d7b9499..5e568fe 100644 --- a/src/MyWebLog/Views/Post.fs +++ b/src/MyWebLog/Views/Post.fs @@ -276,3 +276,246 @@ let list (model: PostDisplay) app = [ p [ _class "text-muted fst-italic text-center" ] [ raw "This web log has no posts" ] ] ] + +let postEdit (model: EditPostModel) templates (ratings: MetaItem list) app = [ + h2 [ _class "my-3" ] [ raw app.PageTitle ] + article [] [ + form [ _action (relUrl app "admin/post/save"); _method "post"; _hxPushUrl "true"; _class "container" ] [ + antiCsrf app + input [ _type "hidden"; _name (nameof model.Id); _value model.Id ] + div [ _class "row mb-3" ] [ + div [ _class "col-12 col-lg-9" ] [ + yield! commonEdit model app + textField [ _class "mb-3" ] (nameof model.Tags) "Tags" model.Tags [ + div [ _class "form-text" ] [ raw "comma-delimited" ] + ] + if model.Status = string Draft then + checkboxSwitch [ _class "mb-2" ] (nameof model.DoPublish) "Publish This Post" model.DoPublish [] + saveButton + hr [ _class "mb-3" ] + fieldset [ _class "mb-3" ] [ + legend [] [ + span [ _class "form-check form-switch" ] [ + small [] [ + input [ _type "checkbox"; _name (nameof model.IsEpisode) + _id (nameof model.IsEpisode); _class "form-check-input"; _value "true" + _data "bs-toggle" "collapse"; _data "bs-target" "#episode_items" + _onclick "Admin.toggleEpisodeFields()"; if model.IsEpisode then _checked ] + ] + label [ _for (nameof model.IsEpisode) ] [ raw "Podcast Episode" ] + ] + ] + div [ _id "episode_items" + _class $"""container p-0 collapse{if model.IsEpisode then " show" else ""}""" ] [ + div [ _class "row" ] [ + div [ _class "col-12 col-md-8 pb-3" ] [ + textField [ _required ] (nameof model.Media) "Media File" model.Media [ + div [ _class "form-text" ] [ + raw "Relative URL will be appended to base media path (if set) " + raw "or served from this web log" + ] + ] + ] + div [ _class "col-12 col-md-4 pb-3" ] [ + textField [] (nameof model.MediaType) "Media MIME Type" model.MediaType [ + div [ _class "form-text" ] [ raw "Optional; overrides podcast default" ] + ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col" ] [ + numberField [ _required ] (nameof model.Length) "Media Length (bytes)" + 0 (* TODO: string model.Length *) [ + div [ _class "form-text" ] [ raw "TODO: derive from above file name" ] + ] + ] + div [ _class "col" ] [ + textField [] (nameof model.Duration) "Duration" model.Duration [ + div [ _class "form-text" ] [ + raw "Recommended; enter in "; code [] [ raw "HH:MM:SS"]; raw " format" + ] + ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col" ] [ + textField [] (nameof model.Subtitle) "Subtitle" model.Subtitle [ + div [ _class "form-text" ] [ raw "Optional; a subtitle for this episode" ] + ] + ] + ] + div [ _class "row" ] [ + div [ _class "col-12 col-md-8 pb-3" ] [ + textField [] (nameof model.ImageUrl) "Image URL" model.ImageUrl [ + div [ _class "form-text" ] [ + raw "Optional; overrides podcast default; " + raw "relative URL served from this web log" + ] + ] + ] + div [ _class "col-12 col-md-4 pb-3" ] [ + selectField [] (nameof model.Explicit) "Explicit Rating" model.Explicit ratings + (_.Name) (_.Value) [ + div [ _class "form-text" ] [ raw "Optional; overrides podcast default" ] + ] + ] + ] + div [ _class "row" ] [ + div [ _class "col-12 col-md-8 pb-3" ] [ + div [ _class "form-text" ] [ raw "Chapters" ] + div [ _class "form-check form-check-inline" ] [ + input [ _type "radio"; _name (nameof model.ChapterSource) + _id "chapter_source_none"; _value "none"; _class "form-check-input" + if model.ChapterSource = "none" then _checked + _onclick "Admin.setChapterSource('none')" ] + label [ _for "chapter_source_none" ] [ raw "None" ] + ] + div [ _class "form-check form-check-inline" ] [ + input [ _type "radio"; _name (nameof model.ChapterSource) + _id "chapter_source_internal"; _value "internal" + _class "form-check-input" + if model.ChapterSource= "internal" then _checked + _onclick "Admin.setChapterSource('internal')" ] + label [ _for "chapter_source_internal" ] [ raw "Defined Here" ] + ] + div [ _class "form-check form-check-inline" ] [ + input [ _type "radio"; _name (nameof model.ChapterSource) + _id "chapter_source_external"; _value "external" + _class "form-check-input" + if model.ChapterSource = "external" then _checked + _onclick "Admin.setChapterSource('external')" ] + label [ _for "chapter_source_external" ] [ raw "Separate File" ] + ] + ] + div [ _class "col-md-4 d-flex justify-content-center" ] [ + checkboxSwitch [ _class "align-self-center pb-3" ] (nameof model.ContainsWaypoints) + "Chapters contain waypoints" model.ContainsWaypoints [] + ] + ] + div [ _class "row" ] [ + div [ _class "col-12 col-md-8 pb-3" ] [ + textField [] (nameof model.ChapterFile) "Chapter File" model.ChapterFile [ + div [ _class "form-text" ] [ raw "Relative URL served from this web log" ] + ] + ] + div [ _class "col-12 col-md-4 pb-3" ] [ + textField [] (nameof model.ChapterType) "Chapter MIME Type" model.ChapterType [ + div [ _class "form-text" ] [ + raw "Optional; "; code [] [ raw "application/json+chapters" ] + raw " assumed if chapter file ends with "; code [] [ raw ".json" ] + ] + ] + ] + ] + div [ _class "row" ] [ + div [ _class "col-12 col-md-8 pb-3" ] [ + textField [] (nameof model.TranscriptUrl) "Transcript URL" model.TranscriptUrl [ + div [ _class "form-text" ] [ + raw "Optional; relative URL served from this web log" + ] + ] + ] + div [ _class "col-12 col-md-4 pb-3" ] [ + textField [] (nameof model.TranscriptType) "Transcript MIME Type" + model.TranscriptType [ + div [ _class "form-text" ] [ raw "Required if transcript URL provided" ] + ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col" ] [ + textField [] (nameof model.TranscriptLang) "Transcript Language" + model.TranscriptLang [ + div [ _class "form-text" ] [ raw "Optional; overrides podcast default" ] + ] + ] + div [ _class "col d-flex justify-content-center" ] [ + checkboxSwitch [ _class "align-self-center pb-3" ] (nameof model.TranscriptCaptions) + "This is a captions file" model.TranscriptCaptions [] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col col-md-4" ] [ + numberField [] (nameof model.SeasonNumber) "Season Number" model.SeasonNumber [ + div [ _class "form-text" ] [ raw "Optional" ] + ] + ] + div [ _class "col col-md-8" ] [ + textField [ _maxlength "128" ] (nameof model.SeasonDescription) "Season Description" + model.SeasonDescription [ + div [ _class "form-text" ] [ raw "Optional" ] + ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col col-md-4" ] [ + numberField [ _step "0.01" ] (nameof model.EpisodeNumber) "Episode Number" + 0 (* TODO: model.EpisodeNumber *) [ + div [ _class "form-text" ] [ raw "Optional; up to 2 decimal points" ] + ] + ] + div [ _class "col col-md-8" ] [ + textField [ _maxlength "128" ] (nameof model.EpisodeDescription) + "Episode Description" model.EpisodeDescription [ + div [ _class "form-text" ] [ raw "Optional" ] + ] + ] + ] + ] + script [] [ + raw """document.addEventListener("DOMContentLoaded", () => Admin.toggleEpisodeFields())""" + ] + ] + commonMetaItems model + if model.Status = string Published then + fieldset [ _class "pb-3" ] [ + legend [] [ raw "Maintenance" ] + div [ _class "container" ] [ + div [ _class "row" ] [ + div [ _class "col align-self-center" ] [ + checkboxSwitch [ _class "pb-2" ] (nameof model.SetPublished) + "Set Published Date" model.SetPublished [] + ] + div [ _class "col-4" ] [ + div [ _class "form-floating" ] [ + input [ _type "datetime-local"; _name (nameof model.PubOverride) + _id (nameof model.PubOverride); _class "form-control" + _placeholder "Override Date" + if model.PubOverride.HasValue then + _value (model.PubOverride.Value.ToString "yyyy-MM-dd\THH:mm") ] + label [ _for (nameof model.PubOverride); _class "form-label" ] [ + raw "Published On" + ] + ] + ] + div [ _class "col-5 align-self-center" ] [ + checkboxSwitch [ _class "pb-2" ] (nameof model.SetUpdated) + "Purge revisions and
    set as updated date as well" + model.SetUpdated [] + ] + ] + ] + ] + ] + div [ _class "col-12 col-lg-3" ] [ + commonTemplates model templates + fieldset [] [ + legend [] [ raw "Categories" ] + for cat in app.Categories do + div [ _class "form-check" ] [ + input [ _type "checkbox"; _name (nameof model.CategoryIds); _id $"category_{cat.Id}" + _class "form-check-input"; _value cat.Id + if model.CategoryIds |> Array.contains cat.Id then _checked ] + label [ _for $"category_{cat.Id}"; _class "form-check-label" + match cat.Description with Some it -> _title it | None -> () ] [ + yield! cat.ParentNames |> Array.map (fun _ -> raw "  ⟩  ") + txt cat.Name + ] + ] + ] + ] + ] + ] + ] + script [] [ raw "window.setTimeout(() => Admin.toggleEpisodeFields(), 500)" ] +] -- 2.45.1 From 64599316d5885223af12c67c9261a8d490729786 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 16 Mar 2024 10:56:55 -0400 Subject: [PATCH 103/123] Eliminate final admin theme Liquid resources - Update admin.js with new field IDs --- src/MyWebLog/Handlers/Admin.fs | 2 +- src/MyWebLog/Handlers/Helpers.fs | 9 +- src/MyWebLog/Handlers/Page.fs | 15 +- src/MyWebLog/Handlers/Post.fs | 15 +- src/MyWebLog/Views/Helpers.fs | 8 + src/admin-theme/_edit-common.liquid | 38 --- src/admin-theme/_layout.liquid | 80 ------ src/admin-theme/layout-bare.liquid | 5 - src/admin-theme/layout-partial.liquid | 5 - src/admin-theme/layout.liquid | 17 -- src/admin-theme/page-edit.liquid | 82 ------- src/admin-theme/post-edit.liquid | 341 -------------------------- src/admin-theme/wwwroot/admin.js | 18 +- 13 files changed, 25 insertions(+), 610 deletions(-) delete mode 100644 src/admin-theme/_edit-common.liquid delete mode 100644 src/admin-theme/_layout.liquid delete mode 100644 src/admin-theme/layout-bare.liquid delete mode 100644 src/admin-theme/layout-partial.liquid delete mode 100644 src/admin-theme/layout.liquid delete mode 100644 src/admin-theme/page-edit.liquid delete mode 100644 src/admin-theme/post-edit.liquid diff --git a/src/MyWebLog/Handlers/Admin.fs b/src/MyWebLog/Handlers/Admin.fs index 2446197..fefd983 100644 --- a/src/MyWebLog/Handlers/Admin.fs +++ b/src/MyWebLog/Handlers/Admin.fs @@ -384,7 +384,7 @@ module Theme = if ctx.Request.HasFormContentType && ctx.Request.Form.Files.Count > 0 then let themeFile = Seq.head ctx.Request.Form.Files match deriveIdFromFileName themeFile.FileName with - | Ok themeId when themeId <> adminTheme -> + | Ok themeId when themeId <> ThemeId "admin" -> let data = ctx.Data let! exists = data.Theme.Exists themeId let isNew = not exists diff --git a/src/MyWebLog/Handlers/Helpers.fs b/src/MyWebLog/Handlers/Helpers.fs index 74cd160..13a0501 100644 --- a/src/MyWebLog/Handlers/Helpers.fs +++ b/src/MyWebLog/Handlers/Helpers.fs @@ -355,13 +355,6 @@ let themedView template next ctx hash = task { return! viewForTheme (hash[ViewContext.WebLog] :?> WebLog).ThemeId template next ctx hash } -/// The ID for the admin theme -let adminTheme = ThemeId "admin" - -/// Display a bare view for the admin theme -let adminBareView template = - bareForTheme adminTheme template - /// Display a page for an admin endpoint let adminPage pageTitle includeCsrf next ctx (content: AppViewContext -> XmlNode list) = task { let! messages = getCurrentMessages ctx @@ -416,7 +409,7 @@ let someTask<'T> (it: 'T) = Task.FromResult(Some it) /// Create an absolute URL from a string that may already be an absolute URL let absoluteUrl (url: string) (ctx: HttpContext) = - if url.StartsWith "http" then url else ctx.WebLog.AbsoluteUrl (Permalink url) + if url.StartsWith "http" then url else ctx.WebLog.AbsoluteUrl(Permalink url) open MyWebLog.Data diff --git a/src/MyWebLog/Handlers/Page.fs b/src/MyWebLog/Handlers/Page.fs index 070976c..2d23bc1 100644 --- a/src/MyWebLog/Handlers/Page.fs +++ b/src/MyWebLog/Handlers/Page.fs @@ -115,18 +115,9 @@ let private findPageRevision pgId revDate (ctx: HttpContext) = task { let previewRevision (pgId, revDate) : HttpHandler = requireAccess Author >=> fun next ctx -> task { match! findPageRevision pgId revDate ctx with | Some pg, Some rev when canEdit pg.AuthorId ctx -> - return! {| - content = - [ """
    """ - rev.Text.AsHtml() |> addBaseToRelativeUrls ctx.WebLog.ExtraPath - "
    " - ] - |> String.concat "" - |} - |> makeHash |> adminBareView "" next ctx + return! adminBarePage "" false next ctx (Views.Helpers.commonPreview rev) | Some _, Some _ -> return! Error.notAuthorized next ctx - | None, _ - | _, None -> return! Error.notFound next ctx + | None, _ | _, None -> return! Error.notFound next ctx } // POST /admin/page/{id}/revision/{revision-date}/restore @@ -150,7 +141,7 @@ let deleteRevision (pgId, revDate) : HttpHandler = requireAccess Author >=> fun | Some pg, Some rev when canEdit pg.AuthorId ctx -> do! ctx.Data.Page.Update { pg with Revisions = pg.Revisions |> List.filter (fun r -> r.AsOf <> rev.AsOf) } do! addMessage ctx { UserMessage.Success with Message = "Revision deleted successfully" } - return! adminBareView "" next ctx (makeHash {| content = "" |}) + return! adminBarePage "" false next ctx (fun _ -> []) | Some _, Some _ -> return! Error.notAuthorized next ctx | None, _ | _, None -> return! Error.notFound next ctx diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index 6b8fec5..216cb8d 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -358,18 +358,9 @@ let private findPostRevision postId revDate (ctx: HttpContext) = task { let previewRevision (postId, revDate) : HttpHandler = requireAccess Author >=> fun next ctx -> task { match! findPostRevision postId revDate ctx with | Some post, Some rev when canEdit post.AuthorId ctx -> - return! {| - content = - [ """
    """ - rev.Text.AsHtml() |> addBaseToRelativeUrls ctx.WebLog.ExtraPath - "
    " - ] - |> String.concat "" - |} - |> makeHash |> adminBareView "" next ctx + return! adminBarePage "" false next ctx (Views.Helpers.commonPreview rev) | Some _, Some _ -> return! Error.notAuthorized next ctx - | None, _ - | _, None -> return! Error.notFound next ctx + | None, _ | _, None -> return! Error.notFound next ctx } // POST /admin/post/{id}/revision/{revision-date}/restore @@ -393,7 +384,7 @@ let deleteRevision (postId, revDate) : HttpHandler = requireAccess Author >=> fu | Some post, Some rev when canEdit post.AuthorId ctx -> do! ctx.Data.Post.Update { post with Revisions = post.Revisions |> List.filter (fun r -> r.AsOf <> rev.AsOf) } do! addMessage ctx { UserMessage.Success with Message = "Revision deleted successfully" } - return! adminBareView "" next ctx (makeHash {| content = "" |}) + return! adminBarePage "" false next ctx (fun _ -> []) | Some _, Some _ -> return! Error.notAuthorized next ctx | None, _ | _, None -> return! Error.notFound next ctx diff --git a/src/MyWebLog/Views/Helpers.fs b/src/MyWebLog/Views/Helpers.fs index 6ddfdc0..cf4d716 100644 --- a/src/MyWebLog/Views/Helpers.fs +++ b/src/MyWebLog/Views/Helpers.fs @@ -393,6 +393,14 @@ let commonMetaItems (model: EditCommonModel) = ] +/// Revision preview template +let commonPreview (rev: Revision) app = + div [ _class "mwl-revision-preview mb-3" ] [ + rev.Text.AsHtml() |> addBaseToRelativeUrls app.WebLog.ExtraPath |> raw + ] + |> List.singleton + + /// Form to manage permalinks for pages or posts let managePermalinks (model: ManagePermalinksModel) app = [ let baseUrl = relUrl app $"admin/{model.Entity}/" diff --git a/src/admin-theme/_edit-common.liquid b/src/admin-theme/_edit-common.liquid deleted file mode 100644 index e72622b..0000000 --- a/src/admin-theme/_edit-common.liquid +++ /dev/null @@ -1,38 +0,0 @@ -
    - - -
    -
    - - - {%- unless model.is_new %} - {%- assign entity_url_base = "admin/" | append: entity | append: "/" | append: entity_id -%} - - Manage Permalinks - - Manage Revisions - {% if model.chapter_source == "internal" %} - - - Manage Chapters - - {% endif %} - - {%- endunless -%} -
    -
    -     -
    - - - - -
    -
    -
    - -
    diff --git a/src/admin-theme/_layout.liquid b/src/admin-theme/_layout.liquid deleted file mode 100644 index 440acea..0000000 --- a/src/admin-theme/_layout.liquid +++ /dev/null @@ -1,80 +0,0 @@ -
    - -
    -
    -
    - {% for msg in messages %} - - {% endfor %} -
    -
    -
    -

    Loading…

    - {{ content }} -
    -
    -
    - {%- assign version = generator | split: " " -%} - v{{ version[1] }} - myWebLog -
    -
    diff --git a/src/admin-theme/layout-bare.liquid b/src/admin-theme/layout-bare.liquid deleted file mode 100644 index 4335d8d..0000000 --- a/src/admin-theme/layout-bare.liquid +++ /dev/null @@ -1,5 +0,0 @@ - - - -{{ content }} - diff --git a/src/admin-theme/layout-partial.liquid b/src/admin-theme/layout-partial.liquid deleted file mode 100644 index fe06a41..0000000 --- a/src/admin-theme/layout-partial.liquid +++ /dev/null @@ -1,5 +0,0 @@ - - - {{ 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 deleted file mode 100644 index 264428c..0000000 --- a/src/admin-theme/layout.liquid +++ /dev/null @@ -1,17 +0,0 @@ - - - - - {{ page_title | strip_html }} « Admin « {{ web_log.name | strip_html }} - - - - {% include_template "_layout" %} - - {{ htmx_script }} - - - diff --git a/src/admin-theme/page-edit.liquid b/src/admin-theme/page-edit.liquid deleted file mode 100644 index 715ec05..0000000 --- a/src/admin-theme/page-edit.liquid +++ /dev/null @@ -1,82 +0,0 @@ -

    {{ page_title }}

    -
    -
    - - -
    -
    -
    - {%- assign entity = "page" -%} - {%- assign entity_id = model.page_id -%} - {% include_template "_edit-common" %} -
    -
    -
    - - -
    -
    - - -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    - - Metadata - - -
    -
    - {%- for meta in metadata %} -
    -
    - -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    - {% endfor -%} -
    - - -
    -
    -
    -
    -
    -
    -
    diff --git a/src/admin-theme/post-edit.liquid b/src/admin-theme/post-edit.liquid deleted file mode 100644 index 03dcdd7..0000000 --- a/src/admin-theme/post-edit.liquid +++ /dev/null @@ -1,341 +0,0 @@ -

    {{ page_title }}

    -
    -
    - - -
    -
    -
    - {%- assign entity = "post" -%} - {%- assign entity_id = model.post_id -%} - {% include_template "_edit-common" %} -
    - - -
    comma-delimited
    -
    - {% if model.status == "Draft" %} -
    - - -
    - {% endif %} - -
    -
    - - - - - - - - -
    -
    -
    -
    - - -
    - Relative URL will be appended to base media path (if set) or served from this web log -
    -
    -
    -
    -
    - - -
    Optional; overrides podcast default
    -
    -
    -
    -
    -
    -
    - - -
    TODO: derive from above file name
    -
    -
    -
    -
    - - -
    Recommended; enter in HH:MM:SS format
    -
    -
    -
    -
    -
    -
    - - -
    Optional; a subtitle for this episode
    -
    -
    -
    -
    -
    -
    - - -
    - Optional; overrides podcast default; relative URL served from this web log -
    -
    -
    -
    -
    - - -
    Optional; overrides podcast default
    -
    -
    -
    -
    -
    -
    Chapters
    -
    - - -
    -
    - - -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    -
    -
    - - -
    Relative URL served from this web log
    -
    -
    -
    -
    - - -
    - Optional; application/json+chapters assumed if chapter file ends with - .json -
    -
    -
    -
    -
    -
    -
    - - -
    Optional; relative URL served from this web log
    -
    -
    -
    -
    - - -
    Required if transcript URL provided
    -
    -
    -
    -
    -
    -
    - - -
    Optional; overrides podcast default
    -
    -
    -
    -
    - - -
    -
    -
    -
    -
    -
    - - -
    Optional
    -
    -
    -
    -
    - - -
    Optional
    -
    -
    -
    -
    -
    -
    - - -
    Optional; up to 2 decimal points
    -
    -
    -
    -
    - - -
    Optional
    -
    -
    -
    -
    - -
    -
    - - Metadata - - -
    -
    - {%- for meta in metadata %} -
    -
    - -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    - {% endfor -%} -
    - - -
    -
    - {% if model.status == "Published" %} -
    - Maintenance -
    -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    -
    - {% endif %} -
    -
    -
    - - -
    -
    - Categories - {% for cat in categories %} -
    - - -
    - {% endfor %} -
    -
    -
    -
    -
    -
    - diff --git a/src/admin-theme/wwwroot/admin.js b/src/admin-theme/wwwroot/admin.js index 7edc1e5..989065c 100644 --- a/src/admin-theme/wwwroot/admin.js +++ b/src/admin-theme/wwwroot/admin.js @@ -217,13 +217,13 @@ this.Admin = { * @param {"none"|"internal"|"external"} src The source for chapters for this episode */ setChapterSource(src) { - document.getElementById("containsWaypoints").disabled = src === "none" + document.getElementById("ContainsWaypoints").disabled = src === "none" const isDisabled = src === "none" || src === "internal" - const chapterFile = document.getElementById("chapterFile") + const chapterFile = document.getElementById("ChapterFile") chapterFile.disabled = isDisabled chapterFile.required = !isDisabled - document.getElementById("chapterType").disabled = isDisabled - const link = document.getElementById("chapterEditLink") + document.getElementById("ChapterType").disabled = isDisabled + const link = document.getElementById("ChapterEditLink") if (link) link.style.display = src === "none" || src === "external" ? "none" : "" }, @@ -231,13 +231,13 @@ this.Admin = { * Enable or disable podcast fields */ toggleEpisodeFields() { - const disabled = !document.getElementById("isEpisode").checked + const disabled = !document.getElementById("IsEpisode").checked let fields = [ - "media", "mediaType", "length", "duration", "subtitle", "imageUrl", "explicit", "transcriptUrl", "transcriptType", - "transcriptLang", "transcriptCaptions", "seasonNumber", "seasonDescription", "episodeNumber", "episodeDescription" + "Media", "MediaType", "Length", "Duration", "Subtitle", "ImageUrl", "Explicit", "TranscriptUrl", "TranscriptType", + "TranscriptLang", "TranscriptCaptions", "SeasonNumber", "SeasonDescription", "EpisodeNumber", "EpisodeDescription" ] if (disabled) { - fields.push("chapterFile", "chapterType", "containsWaypoints") + fields.push("ChapterFile", "ChapterType", "ContainsWaypoints") } else { const src = [...document.getElementsByName("ChapterSource")].filter(it => it.checked)[0].value this.setChapterSource(src) @@ -302,7 +302,7 @@ this.Admin = { * Require transcript type if transcript URL is present */ requireTranscriptType() { - document.getElementById("transcriptType").required = document.getElementById("transcriptUrl").value.trim() !== "" + document.getElementById("TranscriptType").required = document.getElementById("TranscriptUrl").value.trim() !== "" }, /** -- 2.45.1 From 54e46fdeb6c51e129247fdce7b167640f8e419a1 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 16 Mar 2024 12:20:08 -0400 Subject: [PATCH 104/123] Remove unneeded types --- src/MyWebLog.Domain/ViewModels.fs | 281 +++------- src/MyWebLog.Tests/Domain/ViewModelsTests.fs | 528 ++++++++----------- src/MyWebLog/DotLiquidBespoke.fs | 9 +- src/MyWebLog/Handlers/Admin.fs | 2 - src/MyWebLog/Views/WebLog.fs | 63 ++- 5 files changed, 336 insertions(+), 547 deletions(-) diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index 8c69c2c..d52cd10 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -73,82 +73,6 @@ type DisplayCategory = { } -/// A display version of an episode chapter -type DisplayChapter = { - /// The start time of the chapter (H:mm:ss.FF format) - StartTime: string - - /// The title of the chapter - Title: string - - /// An image to display for this chapter - ImageUrl: string - - /// A URL with information about this chapter - Url: string - - /// Whether this chapter should be displayed in podcast players - IsHidden: bool - - /// The end time of the chapter (H:mm:ss.FF format) - EndTime: string - - /// The name of a location - LocationName: string - - /// The geographic coordinates of the location - LocationGeo: string - - /// An OpenStreetMap query for this location - LocationOsm: string -} with - - /// Create a display chapter from a chapter - static member FromChapter (chapter: Chapter) = - let pattern = DurationPattern.CreateWithInvariantCulture "H:mm:ss.FF" - { StartTime = pattern.Format chapter.StartTime - Title = defaultArg chapter.Title "" - ImageUrl = defaultArg chapter.ImageUrl "" - Url = defaultArg chapter.Url "" - IsHidden = defaultArg chapter.IsHidden false - EndTime = chapter.EndTime |> Option.map pattern.Format |> Option.defaultValue "" - LocationName = chapter.Location |> Option.map _.Name |> Option.defaultValue "" - LocationGeo = chapter.Location |> Option.map _.Geo |> Option.defaultValue "" - LocationOsm = chapter.Location |> Option.map _.Osm |> Option.flatten |> Option.defaultValue "" } - - -/// 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 -} with - - /// Create a display version from a custom feed - static member FromFeed (cats: DisplayCategory array) (feed: CustomFeed) = - let source = - match feed.Source with - | Category (CategoryId catId) -> - cats - |> Array.tryFind (fun cat -> cat.Id = catId) - |> Option.map _.Name - |> Option.defaultValue "--INVALID; DELETE THIS FEED--" - |> sprintf "Category: %s" - | Tag tag -> $"Tag: {tag}" - { Id = string feed.Id - Source = source - Path = string feed.Path - IsPodcast = Option.isSome feed.Podcast } - - /// Details about a page used to display page lists [] type DisplayPage = { @@ -269,50 +193,6 @@ type DisplayUpload = { Source = string source } -/// View model to display a user's information -[] -type DisplayUser = { - /// The ID of the user - Id: string - - /// The user name (e-mail address) - Email: string - - /// The user's first name - FirstName: string - - /// The user's last name - LastName: string - - /// The user's preferred name - PreferredName: string - - /// The URL of the user's personal site - Url: string - - /// The user's access level - AccessLevel: string - - /// When the user was created - CreatedOn: DateTime - - /// When the user last logged on - LastSeenOn: Nullable -} with - - /// Construct a displayed user from a web log user - static member FromUser (webLog: WebLog) (user: WebLogUser) = - { Id = string user.Id - Email = user.Email - FirstName = user.FirstName - LastName = user.LastName - PreferredName = user.PreferredName - Url = defaultArg user.Url "" - AccessLevel = string user.AccessLevel - CreatedOn = webLog.LocalTime user.CreatedOn - LastSeenOn = user.LastSeenOn |> Option.map webLog.LocalTime |> Option.toNullable } - - /// View model for editing categories [] type EditCategoryModel = { @@ -386,19 +266,19 @@ type EditChapterModel = { } with /// Create a display chapter from a chapter - static member FromChapter (postId: PostId) idx chapter = - let it = DisplayChapter.FromChapter chapter + static member FromChapter (postId: PostId) idx (chapter: Chapter) = + let pattern = DurationPattern.CreateWithInvariantCulture "H:mm:ss.FF" { PostId = string postId Index = idx - StartTime = it.StartTime - Title = it.Title - ImageUrl = it.ImageUrl - Url = it.Url - IsHidden = it.IsHidden - EndTime = it.EndTime - LocationName = it.LocationName - LocationGeo = it.LocationGeo - LocationOsm = it.LocationOsm + StartTime = pattern.Format chapter.StartTime + Title = defaultArg chapter.Title "" + ImageUrl = defaultArg chapter.ImageUrl "" + Url = defaultArg chapter.Url "" + IsHidden = defaultArg chapter.IsHidden false + EndTime = chapter.EndTime |> Option.map pattern.Format |> Option.defaultValue "" + LocationName = chapter.Location |> Option.map _.Name |> Option.defaultValue "" + LocationGeo = chapter.Location |> Option.map _.Geo |> Option.defaultValue "" + LocationOsm = chapter.Location |> Option.map _.Osm |> Option.flatten |> Option.defaultValue "" AddAnother = false } /// Create a chapter from the values in this model @@ -427,6 +307,76 @@ type EditChapterModel = { Location = location } +/// View model common to page and post edits +type EditCommonModel() = + + /// Find the latest revision within a list of revisions + let findLatestRevision (revs: Revision list) = + match revs |> List.sortByDescending _.AsOf |> List.tryHead with Some rev -> rev | None -> Revision.Empty + + /// The ID of the page or post + member val Id = "" with get, set + + /// The title of the page or post + member val Title = "" with get, set + + /// The permalink for the page or post + member val Permalink = "" with get, set + + /// The entity to which this model applies ("page" or "post") + member val Entity = "" with get, set + + /// Whether to provide a link to manage chapters + member val IncludeChapterLink = false with get, set + + /// The template to use to display the page + member val Template = "" with get, set + + /// The source type ("HTML" or "Markdown") + member val Source = "" with get, set + + /// The text of the page or post + member val Text = "" with get, set + + /// Names of metadata items + member val MetaNames: string array = [||] with get, set + + /// Values of metadata items + member val MetaValues: string array = [||] with get, set + + /// Whether this is a new page or post + member this.IsNew with get () = this.Id = "new" + + /// Fill the properties of this object from a page + member this.PopulateFromPage (page: Page) = + let latest = findLatestRevision page.Revisions + let metaItems = if page.Metadata.Length = 0 then [ MetaItem.Empty ] else page.Metadata + this.Id <- string page.Id + this.Title <- page.Title + this.Permalink <- string page.Permalink + this.Entity <- "page" + this.Template <- defaultArg page.Template "" + this.Source <- latest.Text.SourceType + this.Text <- latest.Text.Text + this.MetaNames <- metaItems |> List.map _.Name |> Array.ofList + this.MetaValues <- metaItems |> List.map _.Value |> Array.ofList + + /// Fill the properties of this object from a post + member this.PopulateFromPost (post: Post) = + let latest = findLatestRevision post.Revisions + let metaItems = if post.Metadata.Length = 0 then [ MetaItem.Empty ] else post.Metadata + this.Id <- string post.Id + this.Title <- post.Title + this.Permalink <- string post.Permalink + this.Entity <- "post" + this.IncludeChapterLink <- Option.isSome post.Episode && Option.isSome post.Episode.Value.Chapters + this.Template <- defaultArg post.Template "" + this.Source <- latest.Text.SourceType + this.Text <- latest.Text.Text + this.MetaNames <- metaItems |> List.map _.Name |> Array.ofList + this.MetaValues <- metaItems |> List.map _.Value |> Array.ofList + + /// View model to edit a custom RSS feed [] type EditCustomFeedModel = { @@ -604,74 +554,6 @@ type EditMyInfoModel = { NewPasswordConfirm = "" } -/// View model common to page and post edits -type EditCommonModel() = - - /// Find the latest revision within a list of revisions - let findLatestRevision (revs: Revision list) = - match revs |> List.sortByDescending _.AsOf |> List.tryHead with Some rev -> rev | None -> Revision.Empty - - /// The ID of the page or post - member val Id = "" with get, set - - /// The title of the page or post - member val Title = "" with get, set - - /// The permalink for the page or post - member val Permalink = "" with get, set - - /// The entity to which this model applies ("page" or "post") - member val Entity = "" with get, set - - /// Whether to provide a link to manage chapters - member val IncludeChapterLink = false with get, set - - /// The template to use to display the page - member val Template = "" with get, set - - /// The source type ("HTML" or "Markdown") - member val Source = "" with get, set - - /// The text of the page or post - member val Text = "" with get, set - - /// Names of metadata items - member val MetaNames: string array = [||] with get, set - - /// Values of metadata items - member val MetaValues: string array = [||] with get, set - - /// Whether this is a new page or post - member this.IsNew with get () = this.Id = "new" - - /// Fill the properties of this object from a page - member this.PopulateFromPage (page: Page) = - let latest = findLatestRevision page.Revisions - this.Id <- string page.Id - this.Title <- page.Title - this.Permalink <- string page.Permalink - this.Entity <- "page" - this.Template <- defaultArg page.Template "" - this.Source <- latest.Text.SourceType - this.Text <- latest.Text.Text - this.MetaNames <- page.Metadata |> List.map _.Name |> Array.ofList - this.MetaValues <- page.Metadata |> List.map _.Value |> Array.ofList - - /// Fill the properties of this object from a post - member this.PopulateFromPost (post: Post) = - let latest = findLatestRevision post.Revisions - this.Id <- string post.Id - this.Title <- post.Title - this.Permalink <- string post.Permalink - this.Entity <- "post" - this.IncludeChapterLink <- Option.isSome post.Episode && Option.isSome post.Episode.Value.Chapters - this.Template <- defaultArg post.Template "" - this.Source <- latest.Text.SourceType - this.Text <- latest.Text.Text - this.MetaNames <- post.Metadata |> List.map _.Name |> Array.ofList - this.MetaValues <- post.Metadata |> List.map _.Value |> Array.ofList - - /// View model to edit a page type EditPageModel() = inherit EditCommonModel() @@ -801,7 +683,6 @@ type EditPostModel() = /// Create an edit model from an existing past static member FromPost (webLog: WebLog) (post: Post) = let model = EditPostModel() - let post = if post.Metadata |> List.isEmpty then { post with Metadata = [ MetaItem.Empty ] } else post model.PopulateFromPost post let episode = defaultArg post.Episode Episode.Empty model.Tags <- post.Tags |> String.concat ", " diff --git a/src/MyWebLog.Tests/Domain/ViewModelsTests.fs b/src/MyWebLog.Tests/Domain/ViewModelsTests.fs index e55e488..da29a2b 100644 --- a/src/MyWebLog.Tests/Domain/ViewModelsTests.fs +++ b/src/MyWebLog.Tests/Domain/ViewModelsTests.fs @@ -33,88 +33,6 @@ let addBaseToRelativeUrlsTests = testList "PublicHelpers.addBaseToRelativeUrls" } ] -/// Unit tests for the DisplayChapter type -let displayChapterTests = testList "DisplayChapter.FromChapter" [ - test "succeeds for a minimally-filled chapter" { - let chapter = DisplayChapter.FromChapter { Chapter.Empty with StartTime = Duration.FromSeconds 322L } - Expect.equal chapter.StartTime "0:05:22" "Start time not filled/formatted properly" - Expect.equal chapter.Title "" "Title not filled properly" - Expect.equal chapter.ImageUrl "" "Image URL not filled properly" - Expect.isFalse chapter.IsHidden "Is hidden flag not filled properly" - Expect.equal chapter.EndTime "" "End time not filled properly" - Expect.equal chapter.LocationName "" "Location name not filled properly" - Expect.equal chapter.LocationGeo "" "Location geo URL not filled properly" - Expect.equal chapter.LocationOsm "" "Location OSM query not filled properly" - } - test "succeeds for a fully-filled chapter" { - let chapter = - DisplayChapter.FromChapter - { StartTime = Duration.FromSeconds 7201.43242 - Title = Some "My Test Chapter" - ImageUrl = Some "two-hours-in.jpg" - Url = Some "https://example.com/about" - IsHidden = Some true - EndTime = Some (Duration.FromSeconds 7313.788) - Location = Some { Name = "Over Here"; Geo = "geo:23432"; Osm = Some "SF98fFSu-8" } } - Expect.equal chapter.StartTime "2:00:01.43" "Start time not filled/formatted properly" - Expect.equal chapter.Title "My Test Chapter" "Title not filled properly" - Expect.equal chapter.ImageUrl "two-hours-in.jpg" "Image URL not filled properly" - Expect.equal chapter.Url "https://example.com/about" "URL not filled properly" - Expect.isTrue chapter.IsHidden "Is hidden flag not filled properly" - Expect.equal chapter.EndTime "2:01:53.78" "End time not filled/formatted properly" - Expect.equal chapter.LocationName "Over Here" "Location name not filled properly" - Expect.equal chapter.LocationGeo "geo:23432" "Location geo URL not filled properly" - Expect.equal chapter.LocationOsm "SF98fFSu-8" "Location OSM query not filled properly" - } -] - -/// Unit tests for the DisplayCustomFeed type -let displayCustomFeedTests = testList "DisplayCustomFeed.FromFeed" [ - test "succeeds for a feed for an existing category" { - let cats = - [| { DisplayCategory.Id = "abc" - Slug = "a-b-c" - Name = "My Lovely Category" - Description = None - ParentNames = [||] - PostCount = 3 } |] - let feed = - { CustomFeed.Empty with - Id = CustomFeedId "test-feed" - Source = Category (CategoryId "abc") - Path = Permalink "test-feed.xml" } - let model = DisplayCustomFeed.FromFeed cats feed - Expect.equal model.Id "test-feed" "Id not filled properly" - Expect.equal model.Source "Category: My Lovely Category" "Source not filled properly" - Expect.equal model.Path "test-feed.xml" "Path not filled properly" - Expect.isFalse model.IsPodcast "IsPodcast not filled properly" - } - test "succeeds for a feed for a non-existing category" { - let feed = - { CustomFeed.Empty with - Id = CustomFeedId "bad-feed" - Source = Category (CategoryId "xyz") - Path = Permalink "trouble.xml" } - let model = DisplayCustomFeed.FromFeed [||] feed - Expect.equal model.Id "bad-feed" "Id not filled properly" - Expect.equal model.Source "Category: --INVALID; DELETE THIS FEED--" "Source not filled properly" - Expect.equal model.Path "trouble.xml" "Path not filled properly" - Expect.isFalse model.IsPodcast "IsPodcast not filled properly" - } - test "succeeds for a feed for a tag" { - let feed = - { Id = CustomFeedId "tag-feed" - Source = Tag "testing" - Path = Permalink "testing-posts.xml" - Podcast = Some PodcastOptions.Empty } - let model = DisplayCustomFeed.FromFeed [||] feed - Expect.equal model.Id "tag-feed" "Id not filled properly" - Expect.equal model.Source "Tag: testing" "Source not filled properly" - Expect.equal model.Path "testing-posts.xml" "Path not filled properly" - Expect.isTrue model.IsPodcast "IsPodcast not filled properly" - } -] - /// Unit tests for the DisplayPage type let displayPageTests = testList "DisplayPage" [ let page = @@ -242,47 +160,6 @@ let displayUploadTests = test "DisplayUpload.FromUpload succeeds" { model.UpdatedOn.Value ((Noda.epoch + Duration.FromHours 1).ToDateTimeUtc()) "UpdatedOn not filled properly" } -/// Unit tests for the DisplayUser type -let displayUserTests = testList "DisplayUser.FromUser" [ - let minimalUser = - { WebLogUser.Empty with - Id = WebLogUserId "test-user" - Email = "jim.james@example.com" - FirstName = "Jim" - LastName = "James" - PreferredName = "John" - AccessLevel = Editor - CreatedOn = Noda.epoch } - test "succeeds when the user has minimal information" { - let model = DisplayUser.FromUser WebLog.Empty minimalUser - Expect.equal model.Id "test-user" "Id not filled properly" - Expect.equal model.Email "jim.james@example.com" "Email not filled properly" - Expect.equal model.FirstName "Jim" "FirstName not filled properly" - Expect.equal model.LastName "James" "LastName not filled properly" - Expect.equal model.PreferredName "John" "PreferredName not filled properly" - Expect.equal model.Url "" "Url not filled properly" - Expect.equal model.AccessLevel "Editor" "AccessLevel not filled properly" - Expect.equal model.CreatedOn (Noda.epoch.ToDateTimeUtc()) "CreatedOn not filled properly" - Expect.isFalse model.LastSeenOn.HasValue "LastSeenOn should have been null" - } - test "succeeds when the user has all information" { - let model = - DisplayUser.FromUser - { WebLog.Empty with TimeZone = "Etc/GMT-1" } - { minimalUser with - Url = Some "https://my.site" - LastSeenOn = Some (Noda.epoch + Duration.FromDays 4) } - Expect.equal model.Url "https://my.site" "Url not filled properly" - Expect.equal - model.CreatedOn ((Noda.epoch + Duration.FromHours 1).ToDateTimeUtc()) "CreatedOn not filled properly" - Expect.isTrue model.LastSeenOn.HasValue "LastSeenOn should not have been null" - Expect.equal - model.LastSeenOn.Value - ((Noda.epoch + Duration.FromDays 4 + Duration.FromHours 1).ToDateTimeUtc()) - "LastSeenOn not filled properly" - } - ] - /// Unit tests for the EditCategoryModel type let editCategoryModelTests = testList "EditCategoryModel" [ testList "FromCategory" [ @@ -315,6 +192,131 @@ let editCategoryModelTests = testList "EditCategoryModel" [ ] ] +/// A full page used to test various models +let private testFullPage = + { Page.Empty with + Id = PageId "the-page" + Title = "Test Page" + Permalink = Permalink "blog/page.html" + Template = Some "bork" + IsInPageList = true + Revisions = + [ { AsOf = Noda.epoch + Duration.FromHours 1; Text = Markdown "# Howdy!" } + { AsOf = Noda.epoch; Text = Html "

    howdy

    " } ] + Metadata = [ { Name = "Test"; Value = "me" }; { Name = "Two"; Value = "2" } ] } + +/// A full post used to test various models +let testFullPost = + { Post.Empty with + Id = PostId "a-post" + Status = Published + Title = "A Post" + Permalink = Permalink "1970/01/a-post.html" + PublishedOn = Some (Noda.epoch + Duration.FromDays 7) + UpdatedOn = Noda.epoch + Duration.FromDays 365 + Template = Some "demo" + Text = "

    A post!

    " + CategoryIds = [ CategoryId "cat-a"; CategoryId "cat-b"; CategoryId "cat-n" ] + Tags = [ "demo"; "post" ] + Metadata = [ { Name = "A Meta"; Value = "A Value" } ] + Revisions = + [ { AsOf = Noda.epoch + Duration.FromDays 365; Text = Html "

    A post!

    " } + { AsOf = Noda.epoch + Duration.FromDays 7; Text = Markdown "A post!" } ] + Episode = + Some { Media = "a-post-ep.mp3" + Length = 15555L + Duration = Some (Duration.FromMinutes 15L + Duration.FromSeconds 22L) + MediaType = Some "audio/mpeg3" + ImageUrl = Some "uploads/podcast-cover.jpg" + Subtitle = Some "Narration" + Explicit = Some Clean + Chapters = None + ChapterFile = Some "uploads/1970/01/chapters.txt" + ChapterType = Some "chapters" + ChapterWaypoints = Some true + TranscriptUrl = Some "uploads/1970/01/transcript.txt" + TranscriptType = Some "transcript" + TranscriptLang = Some "EN-us" + TranscriptCaptions = Some true + SeasonNumber = Some 3 + SeasonDescription = Some "Season Three" + EpisodeNumber = Some 322. + EpisodeDescription = Some "Episode 322" } } + +/// Unit tests for the EditCommonModel type +let editCommonModelTests = testList "EditCommonModel" [ + testList "IsNew" [ + test "succeeds for a new page or post" { + Expect.isTrue (EditCommonModel(Id = "new")).IsNew "IsNew should have been set" + } + test "succeeds for an existing page or post" { + Expect.isFalse (EditCommonModel(Id = string (PageId.Create ()))).IsNew "IsNew should not have been set" + } + ] + testList "PopulateFromPage" [ + test "succeeds for empty page" { + let model = EditCommonModel() + model.PopulateFromPage { Page.Empty with Id = PageId "abc" } + Expect.equal model.Id "abc" "PageId not filled properly" + Expect.equal model.Title "" "Title not filled properly" + Expect.equal model.Permalink "" "Permalink not filled properly" + Expect.equal model.Template "" "Template not filled properly" + Expect.equal model.Source "HTML" "Source not filled properly" + Expect.equal model.Text "" "Text not set properly" + Expect.equal model.MetaNames.Length 1 "MetaNames should have one entry" + Expect.equal model.MetaNames[0] "" "Meta name not set properly" + Expect.equal model.MetaValues.Length 1 "MetaValues should have one entry" + Expect.equal model.MetaValues[0] "" "Meta value not set properly" + } + test "succeeds for filled page" { + let model = EditCommonModel() + model.PopulateFromPage testFullPage + Expect.equal model.Id "the-page" "PageId not filled properly" + Expect.equal model.Title "Test Page" "Title not filled properly" + Expect.equal model.Permalink "blog/page.html" "Permalink not filled properly" + Expect.equal model.Template "bork" "Template not filled properly" + Expect.equal model.Source "Markdown" "Source not filled properly" + Expect.equal model.Text "# Howdy!" "Text not filled properly" + Expect.equal model.MetaNames.Length 2 "MetaNames should have two entries" + Expect.equal model.MetaNames[0] "Test" "Meta name 0 not set properly" + Expect.equal model.MetaNames[1] "Two" "Meta name 1 not set properly" + Expect.equal model.MetaValues.Length 2 "MetaValues should have two entries" + Expect.equal model.MetaValues[0] "me" "Meta value 0 not set properly" + Expect.equal model.MetaValues[1] "2" "Meta value 1 not set properly" + } + ] + testList "PopulateFromPost" [ + test "succeeds for empty post" { + let model = EditCommonModel() + model.PopulateFromPost { Post.Empty with Id = PostId "la-la-la" } + Expect.equal model.Id "la-la-la" "PostId not filled properly" + Expect.equal model.Title "" "Title not filled properly" + Expect.equal model.Permalink "" "Permalink not filled properly" + Expect.equal model.Source "HTML" "Source not filled properly" + Expect.equal model.Text "" "Text not filled properly" + Expect.equal model.Template "" "Template not filled properly" + Expect.equal model.MetaNames.Length 1 "MetaNames not filled properly" + Expect.equal model.MetaNames[0] "" "Meta name 0 not filled properly" + Expect.equal model.MetaValues.Length 1 "MetaValues not filled properly" + Expect.equal model.MetaValues[0] "" "Meta value 0 not filled properly" + } + test "succeeds for full post with external chapters" { + let model = EditCommonModel() + model.PopulateFromPost testFullPost + Expect.equal model.Id "a-post" "PostId not filled properly" + Expect.equal model.Title "A Post" "Title not filled properly" + Expect.equal model.Permalink "1970/01/a-post.html" "Permalink not filled properly" + Expect.equal model.Source "HTML" "Source not filled properly" + Expect.equal model.Text "

    A post!

    " "Text not filled properly" + Expect.equal model.Template "demo" "Template not filled properly" + Expect.equal model.MetaNames.Length 1 "MetaNames not filled properly" + Expect.equal model.MetaNames[0] "A Meta" "Meta name 0 not filled properly" + Expect.equal model.MetaValues.Length 1 "MetaValues not filled properly" + Expect.equal model.MetaValues[0] "A Value" "Meta value 0 not filled properly" + } + ] +] + /// Unit tests for the EditCustomFeedModel type let editCustomFeedModelTests = testList "EditCustomFeedModel" [ let minimalPodcast = @@ -502,63 +504,26 @@ let editMyInfoModelTests = test "EditMyInfoModel.FromUser succeeds" { Expect.equal model.NewPasswordConfirm "" "NewPasswordConfirm not filled properly" } +/// Unit tests for the EditPageModel type let editPageModelTests = testList "EditPageModel" [ - let fullPage = - { Page.Empty with - Id = PageId "the-page" - Title = "Test Page" - Permalink = Permalink "blog/page.html" - Template = Some "bork" - IsInPageList = true - Revisions = - [ { AsOf = Noda.epoch + Duration.FromHours 1; Text = Markdown "# Howdy!" } - { AsOf = Noda.epoch; Text = Html "

    howdy

    " } ] - Metadata = [ { Name = "Test"; Value = "me" }; { Name = "Two"; Value = "2" } ] } testList "FromPage" [ test "succeeds for empty page" { let model = EditPageModel.FromPage { Page.Empty with Id = PageId "abc" } - Expect.equal model.PageId "abc" "PageId not filled properly" - Expect.equal model.Title "" "Title not filled properly" - Expect.equal model.Permalink "" "Permalink not filled properly" - Expect.equal model.Template "" "Template not filled properly" + Expect.equal model.Id "abc" "Parent fields not filled properly" Expect.isFalse model.IsShownInPageList "IsShownInPageList should not have been set" - Expect.equal model.Source "HTML" "Source not filled properly" - Expect.equal model.Text "" "Text not set properly" - Expect.equal model.MetaNames.Length 1 "MetaNames should have one entry" - Expect.equal model.MetaNames[0] "" "Meta name not set properly" - Expect.equal model.MetaValues.Length 1 "MetaValues should have one entry" - Expect.equal model.MetaValues[0] "" "Meta value not set properly" } test "succeeds for filled page" { - let model = EditPageModel.FromPage fullPage - Expect.equal model.PageId "the-page" "PageId not filled properly" - Expect.equal model.Title "Test Page" "Title not filled properly" - Expect.equal model.Permalink "blog/page.html" "Permalink not filled properly" - Expect.equal model.Template "bork" "Template not filled properly" + let model = EditPageModel.FromPage testFullPage + Expect.equal model.Id "the-page" "Parent fields not filled properly" Expect.isTrue model.IsShownInPageList "IsShownInPageList should have been set" - Expect.equal model.Source "Markdown" "Source not filled properly" - Expect.equal model.Text "# Howdy!" "Text not filled properly" - Expect.equal model.MetaNames.Length 2 "MetaNames should have two entries" - Expect.equal model.MetaNames[0] "Test" "Meta name 0 not set properly" - Expect.equal model.MetaNames[1] "Two" "Meta name 1 not set properly" - Expect.equal model.MetaValues.Length 2 "MetaValues should have two entries" - Expect.equal model.MetaValues[0] "me" "Meta value 0 not set properly" - Expect.equal model.MetaValues[1] "2" "Meta value 1 not set properly" - } - ] - testList "IsNew" [ - test "succeeds for a new page" { - Expect.isTrue - (EditPageModel.FromPage { Page.Empty with Id = PageId "new" }).IsNew "IsNew should have been set" - } - test "succeeds for an existing page" { - Expect.isFalse (EditPageModel.FromPage Page.Empty).IsNew "IsNew should not have been set" } ] testList "UpdatePage" [ test "succeeds with minimal changes" { - let model = { EditPageModel.FromPage fullPage with Title = "Updated Page"; IsShownInPageList = false } - let page = model.UpdatePage fullPage (Noda.epoch + Duration.FromHours 4) + let model = EditPageModel.FromPage testFullPage + model.Title <- "Updated Page" + model.IsShownInPageList <- false + let page = model.UpdatePage testFullPage (Noda.epoch + Duration.FromHours 4) Expect.equal page.Title "Updated Page" "Title not filled properly" Expect.equal page.Permalink (Permalink "blog/page.html") "Permalink not filled properly" Expect.isEmpty page.PriorPermalinks "PriorPermalinks should be empty" @@ -582,18 +547,18 @@ let editPageModelTests = testList "EditPageModel" [ Expect.equal rev2.Text (Html "

    howdy

    ") "Revision 1 text not filled properly" } test "succeeds with all changes" { - let model = - { PageId = "this-page" - Title = "My Updated Page" - Permalink = "blog/updated.html" - Template = "" - IsShownInPageList = false - Source = "HTML" - Text = "

    Howdy, partners!

    " - MetaNames = [| "banana"; "apple"; "grape" |] - MetaValues = [| "monkey"; "zebra"; "ape" |] } + let model = EditPageModel() + model.Id <- "this-page" + model.Title <- "My Updated Page" + model.Permalink <- "blog/updated.html" + model.Template <- "" + model.IsShownInPageList <- false + model.Source <- "HTML" + model.Text <- "

    Howdy, partners!

    " + model.MetaNames <- [| "banana"; "apple"; "grape" |] + model.MetaValues <- [| "monkey"; "zebra"; "ape" |] let now = Noda.epoch + Duration.FromDays 7 - let page = model.UpdatePage fullPage now + let page = model.UpdatePage testFullPage now Expect.equal page.Title "My Updated Page" "Title not filled properly" Expect.equal page.Permalink (Permalink "blog/updated.html") "Permalink not filled properly" Expect.equal page.PriorPermalinks [ Permalink "blog/page.html" ] "PriorPermalinks not filled properly" @@ -621,59 +586,14 @@ let editPageModelTests = testList "EditPageModel" [ /// Unit tests for the EditPostModel type let editPostModelTests = testList "EditPostModel" [ - let fullPost = - { Post.Empty with - Id = PostId "a-post" - Status = Published - Title = "A Post" - Permalink = Permalink "1970/01/a-post.html" - PublishedOn = Some (Noda.epoch + Duration.FromDays 7) - UpdatedOn = Noda.epoch + Duration.FromDays 365 - Template = Some "demo" - Text = "

    A post!

    " - CategoryIds = [ CategoryId "cat-a"; CategoryId "cat-b"; CategoryId "cat-n" ] - Tags = [ "demo"; "post" ] - Metadata = [ { Name = "A Meta"; Value = "A Value" } ] - Revisions = - [ { AsOf = Noda.epoch + Duration.FromDays 365; Text = Html "

    A post!

    " } - { AsOf = Noda.epoch + Duration.FromDays 7; Text = Markdown "A post!" } ] - Episode = - Some { Media = "a-post-ep.mp3" - Length = 15555L - Duration = Some (Duration.FromMinutes 15L + Duration.FromSeconds 22L) - MediaType = Some "audio/mpeg3" - ImageUrl = Some "uploads/podcast-cover.jpg" - Subtitle = Some "Narration" - Explicit = Some Clean - Chapters = None - ChapterFile = Some "uploads/1970/01/chapters.txt" - ChapterType = Some "chapters" - ChapterWaypoints = Some true - TranscriptUrl = Some "uploads/1970/01/transcript.txt" - TranscriptType = Some "transcript" - TranscriptLang = Some "EN-us" - TranscriptCaptions = Some true - SeasonNumber = Some 3 - SeasonDescription = Some "Season Three" - EpisodeNumber = Some 322. - EpisodeDescription = Some "Episode 322" } } testList "FromPost" [ test "succeeds for empty post" { let model = EditPostModel.FromPost WebLog.Empty { Post.Empty with Id = PostId "la-la-la" } - Expect.equal model.PostId "la-la-la" "PostId not filled properly" - Expect.equal model.Title "" "Title not filled properly" - Expect.equal model.Permalink "" "Permalink not filled properly" - Expect.equal model.Source "HTML" "Source not filled properly" - Expect.equal model.Text "" "Text not filled properly" + Expect.equal model.Id "la-la-la" "Parent fields not filled properly" Expect.equal model.Tags "" "Tags not filled properly" - Expect.equal model.Template "" "Template not filled properly" Expect.isEmpty model.CategoryIds "CategoryIds not filled properly" Expect.equal model.Status (string Draft) "Status not filled properly" Expect.isFalse model.DoPublish "DoPublish should not have been set" - Expect.equal model.MetaNames.Length 1 "MetaNames not filled properly" - Expect.equal model.MetaNames[0] "" "Meta name 0 not filled properly" - Expect.equal model.MetaValues.Length 1 "MetaValues not filled properly" - Expect.equal model.MetaValues[0] "" "Meta value 0 not filled properly" Expect.isFalse model.SetPublished "SetPublished should not have been set" Expect.isFalse model.PubOverride.HasValue "PubOverride not filled properly" Expect.isFalse model.SetUpdated "SetUpdated should not have been set" @@ -699,21 +619,12 @@ let editPostModelTests = testList "EditPostModel" [ Expect.equal model.EpisodeDescription "" "EpisodeDescription not filled properly" } test "succeeds for full post with external chapters" { - let model = EditPostModel.FromPost { WebLog.Empty with TimeZone = "Etc/GMT+1" } fullPost - Expect.equal model.PostId "a-post" "PostId not filled properly" - Expect.equal model.Title "A Post" "Title not filled properly" - Expect.equal model.Permalink "1970/01/a-post.html" "Permalink not filled properly" - Expect.equal model.Source "HTML" "Source not filled properly" - Expect.equal model.Text "

    A post!

    " "Text not filled properly" + let model = EditPostModel.FromPost { WebLog.Empty with TimeZone = "Etc/GMT+1" } testFullPost + Expect.equal model.Id "a-post" "Parent fields not filled properly" Expect.equal model.Tags "demo, post" "Tags not filled properly" - Expect.equal model.Template "demo" "Template not filled properly" Expect.equal model.CategoryIds [| "cat-a"; "cat-b"; "cat-n" |] "CategoryIds not filled properly" Expect.equal model.Status (string Published) "Status not filled properly" Expect.isFalse model.DoPublish "DoPublish should not have been set" - Expect.equal model.MetaNames.Length 1 "MetaNames not filled properly" - Expect.equal model.MetaNames[0] "A Meta" "Meta name 0 not filled properly" - Expect.equal model.MetaValues.Length 1 "MetaValues not filled properly" - Expect.equal model.MetaValues[0] "A Value" "Meta value 0 not filled properly" Expect.isFalse model.SetPublished "SetPublished should not have been set" Expect.isTrue model.PubOverride.HasValue "PubOverride should not have been null" Expect.equal @@ -746,63 +657,52 @@ let editPostModelTests = testList "EditPostModel" [ let model = EditPostModel.FromPost { WebLog.Empty with TimeZone = "Etc/GMT+1" } - { fullPost with + { testFullPost with Episode = Some - { fullPost.Episode.Value with + { testFullPost.Episode.Value with Chapters = Some [] ChapterFile = None ChapterType = None } } Expect.equal model.ChapterSource "internal" "ChapterSource not filled properly" } ] - testList "IsNew" [ - test "succeeds for a new post" { - Expect.isTrue - (EditPostModel.FromPost WebLog.Empty { Post.Empty with Id = PostId "new" }).IsNew - "IsNew should be set for new post" - } - test "succeeds for a not-new post" { - Expect.isFalse - (EditPostModel.FromPost WebLog.Empty { Post.Empty with Id = PostId "nu" }).IsNew - "IsNew should not be set for not-new post" - } - ] - let updatedModel = - { EditPostModel.FromPost WebLog.Empty fullPost with - Title = "An Updated Post" - Permalink = "1970/01/updated-post.html" - Source = "HTML" - Text = "

    An updated post!

    " - Tags = "Zebras, Aardvarks, , Turkeys" - Template = "updated" - CategoryIds = [| "cat-x"; "cat-y" |] - MetaNames = [| "Zed Meta"; "A Meta" |] - MetaValues = [| "A Value"; "Zed Value" |] - Media = "an-updated-ep.mp3" - Length = 14444L - Duration = "0:14:42" - MediaType = "audio/mp3" - ImageUrl = "updated-cover.png" - Subtitle = "Talking" - Explicit = "no" - ChapterSource = "external" - ChapterFile = "updated-chapters.txt" - ChapterType = "indexes" - TranscriptUrl = "updated-transcript.txt" - TranscriptType = "subtitles" - TranscriptLang = "ES-mx" - SeasonNumber = 4 - SeasonDescription = "Season Fo" - EpisodeNumber = "432.1" - EpisodeDescription = "Four Three Two pt One" } + let updatedModel () = + let model = EditPostModel.FromPost WebLog.Empty testFullPost + model.Title <- "An Updated Post" + model.Permalink <- "1970/01/updated-post.html" + model.Source <- "HTML" + model.Text <- "

    An updated post!

    " + model.Tags <- "Zebras, Aardvarks, , Turkeys" + model.Template <- "updated" + model.CategoryIds <- [| "cat-x"; "cat-y" |] + model.MetaNames <- [| "Zed Meta"; "A Meta" |] + model.MetaValues <- [| "A Value"; "Zed Value" |] + model.Media <- "an-updated-ep.mp3" + model.Length <- 14444L + model.Duration <- "0:14:42" + model.MediaType <- "audio/mp3" + model.ImageUrl <- "updated-cover.png" + model.Subtitle <- "Talking" + model.Explicit <- "no" + model.ChapterSource <- "external" + model.ChapterFile <- "updated-chapters.txt" + model.ChapterType <- "indexes" + model.TranscriptUrl <- "updated-transcript.txt" + model.TranscriptType <- "subtitles" + model.TranscriptLang <- "ES-mx" + model.SeasonNumber <- 4 + model.SeasonDescription <- "Season Fo" + model.EpisodeNumber <- "432.1" + model.EpisodeDescription <- "Four Three Two pt One" + model testList "UpdatePost" [ test "succeeds for a full podcast episode" { - let post = updatedModel.UpdatePost fullPost (Noda.epoch + Duration.FromDays 400) + let post = (updatedModel ()).UpdatePost testFullPost (Noda.epoch + Duration.FromDays 400) Expect.equal post.Title "An Updated Post" "Title not filled properly" Expect.equal post.Permalink (Permalink "1970/01/updated-post.html") "Permalink not filled properly" Expect.equal post.PriorPermalinks [ Permalink "1970/01/a-post.html" ] "PriorPermalinks not filled properly" - Expect.equal post.PublishedOn fullPost.PublishedOn "PublishedOn should not have changed" + Expect.equal post.PublishedOn testFullPost.PublishedOn "PublishedOn should not have changed" Expect.equal post.UpdatedOn (Noda.epoch + Duration.FromDays 400) "UpdatedOn not filled properly" Expect.equal post.Text "

    An updated post!

    " "Text not filled properly" Expect.equal post.Tags [ "aardvarks"; "turkeys"; "zebras" ] "Tags not filled properly" @@ -841,25 +741,24 @@ let editPostModelTests = testList "EditPostModel" [ Expect.equal ep.EpisodeDescription (Some "Four Three Two pt One") "EpisodeDescription not filled properly" } test "succeeds for a minimal podcast episode" { - let minModel = - { updatedModel with - Duration = "" - MediaType = "" - ImageUrl = "" - Subtitle = "" - Explicit = "" - ChapterFile = "" - ChapterType = "" - ContainsWaypoints = false - TranscriptUrl = "" - TranscriptType = "" - TranscriptLang = "" - TranscriptCaptions = false - SeasonNumber = 0 - SeasonDescription = "" - EpisodeNumber = "" - EpisodeDescription = "" } - let post = minModel.UpdatePost fullPost (Noda.epoch + Duration.FromDays 500) + let minModel = updatedModel () + minModel.Duration <- "" + minModel.MediaType <- "" + minModel.ImageUrl <- "" + minModel.Subtitle <- "" + minModel.Explicit <- "" + minModel.ChapterFile <- "" + minModel.ChapterType <- "" + minModel.ContainsWaypoints <- false + minModel.TranscriptUrl <- "" + minModel.TranscriptType <- "" + minModel.TranscriptLang <- "" + minModel.TranscriptCaptions <- false + minModel.SeasonNumber <- 0 + minModel.SeasonDescription <- "" + minModel.EpisodeNumber <- "" + minModel.EpisodeDescription <- "" + let post = minModel.UpdatePost testFullPost (Noda.epoch + Duration.FromDays 500) Expect.isSome post.Episode "There should have been a podcast episode" let ep = post.Episode.Value Expect.equal ep.Media "an-updated-ep.mp3" "Media not filled properly" @@ -882,12 +781,11 @@ let editPostModelTests = testList "EditPostModel" [ Expect.isNone ep.EpisodeDescription "EpisodeDescription not filled properly" } test "succeeds for a podcast episode with internal chapters" { - let minModel = - { updatedModel with - ChapterSource = "internal" - ChapterFile = "" - ChapterType = "" } - let post = minModel.UpdatePost fullPost (Noda.epoch + Duration.FromDays 500) + let minModel = updatedModel () + minModel.ChapterSource <- "internal" + minModel.ChapterFile <- "" + minModel.ChapterType <- "" + let post = minModel.UpdatePost testFullPost (Noda.epoch + Duration.FromDays 500) Expect.isSome post.Episode "There should have been a podcast episode" let ep = post.Episode.Value Expect.equal ep.Chapters (Some []) "Chapters not filled properly" @@ -895,10 +793,11 @@ let editPostModelTests = testList "EditPostModel" [ Expect.isNone ep.ChapterType "ChapterType not filled properly" } test "succeeds for a podcast episode with no chapters" { - let minModel = { updatedModel with ChapterSource = "none" } + let minModel = updatedModel () + minModel.ChapterSource <- "none" let post = minModel.UpdatePost - { fullPost with Episode = Some { fullPost.Episode.Value with Chapters = Some [] } } + { testFullPost with Episode = Some { testFullPost.Episode.Value with Chapters = Some [] } } (Noda.epoch + Duration.FromDays 500) Expect.isSome post.Episode "There should have been a podcast episode" let ep = post.Episode.Value @@ -908,14 +807,17 @@ let editPostModelTests = testList "EditPostModel" [ Expect.isNone ep.ChapterWaypoints "ChapterWaypoints not filled properly" } test "succeeds for no podcast episode and no template" { - let post = { updatedModel with IsEpisode = false; Template = "" }.UpdatePost fullPost Noda.epoch + let model = updatedModel () + model.IsEpisode <- false + model.Template <- "" + let post = model.UpdatePost testFullPost Noda.epoch Expect.isNone post.Template "Template not filled properly" Expect.isNone post.Episode "Episode not filled properly" } test "succeeds when publishing a draft" { - let post = - { updatedModel with DoPublish = true }.UpdatePost - { fullPost with Status = Draft } (Noda.epoch + Duration.FromDays 375) + let model = updatedModel () + model.DoPublish <- true + let post = model.UpdatePost { testFullPost with Status = Draft } (Noda.epoch + Duration.FromDays 375) Expect.equal post.Status Published "Status not set properly" Expect.equal post.PublishedOn (Some (Noda.epoch + Duration.FromDays 375)) "PublishedOn not set properly" } @@ -1322,13 +1224,11 @@ let userMessageTests = testList "UserMessage" [ /// All tests in the Domain.ViewModels file let all = testList "ViewModels" [ addBaseToRelativeUrlsTests - displayChapterTests - displayCustomFeedTests displayPageTests displayThemeTests displayUploadTests - displayUserTests editCategoryModelTests + editCommonModelTests editCustomFeedModelTests editMyInfoModelTests editPageModelTests diff --git a/src/MyWebLog/DotLiquidBespoke.fs b/src/MyWebLog/DotLiquidBespoke.fs index 872486b..e68f448 100644 --- a/src/MyWebLog/DotLiquidBespoke.fs +++ b/src/MyWebLog/DotLiquidBespoke.fs @@ -225,12 +225,11 @@ let register () = Template.RegisterTag "user_links" [ // Domain types - typeof; typeof; typeof; typeof; typeof - typeof; typeof; typeof; typeof; typeof + typeof; typeof; 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 + typeof; typeof // Framework types typeof; typeof; typeof; typeof typeof; typeof; typeof; typeof diff --git a/src/MyWebLog/Handlers/Admin.fs b/src/MyWebLog/Handlers/Admin.fs index fefd983..6bca839 100644 --- a/src/MyWebLog/Handlers/Admin.fs +++ b/src/MyWebLog/Handlers/Admin.fs @@ -455,11 +455,9 @@ module WebLog = |> List.append [ { Page.Empty with Id = PageId "posts"; Title = "- First Page of Posts -" } ] let! themes = data.Theme.All() let uploads = [ Database; Disk ] - let feeds = ctx.WebLog.Rss.CustomFeeds |> List.map (DisplayCustomFeed.FromFeed (CategoryCache.get ctx)) return! Views.WebLog.webLogSettings (SettingsModel.FromWebLog ctx.WebLog) themes pages uploads (EditRssModel.FromRssOptions ctx.WebLog.Rss) - feeds |> adminPage "Web Log Settings" true next ctx } diff --git a/src/MyWebLog/Views/WebLog.fs b/src/MyWebLog/Views/WebLog.fs index 2aeff71..a0f2cb6 100644 --- a/src/MyWebLog/Views/WebLog.fs +++ b/src/MyWebLog/Views/WebLog.fs @@ -699,7 +699,40 @@ let uploadNew app = [ /// Web log settings page let webLogSettings (model: SettingsModel) (themes: Theme list) (pages: Page list) (uploads: UploadDestination list) - (rss: EditRssModel) (feeds: DisplayCustomFeed list) app = [ + (rss: EditRssModel) (app: AppViewContext) = [ + let feedDetail (feed: CustomFeed) = + let source = + match feed.Source with + | Category (CategoryId catId) -> + app.Categories + |> Array.tryFind (fun cat -> cat.Id = catId) + |> Option.map _.Name + |> Option.defaultValue "--INVALID; DELETE THIS FEED--" + |> sprintf "Category: %s" + | Tag tag -> $"Tag: {tag}" + div [ _class "row mwl-table-detail" ] [ + div [ _class "col-12 col-md-6" ] [ + txt source + if Option.isSome feed.Podcast then + raw "   "; span [ _class "badge bg-primary" ] [ raw "PODCAST" ] + br [] + small [] [ + let feedUrl = relUrl app $"admin/settings/rss/{feed.Id}" + a [ _href (relUrl app (string feed.Path)); _target "_blank" ] [ raw "View Feed" ] + actionSpacer + a [ _href $"{feedUrl}/edit" ] [ raw "Edit" ]; actionSpacer + a [ _href feedUrl; _hxDelete feedUrl; _class "text-danger" + _hxConfirm $"Are you sure you want to delete the custom RSS feed based on {feed.Source}? This action cannot be undone." ] [ + raw "Delete" + ] + ] + ] + div [ _class "col-12 col-md-6" ] [ + small [ _class "d-md-none" ] [ raw "Served at "; txt (string feed.Path) ] + span [ _class "d-none d-md-inline" ] [ txt (string feed.Path) ] + ] + ] + h2 [ _class "my-3" ] [ txt app.WebLog.Name; raw " Settings" ] article [] [ p [ _class "text-muted" ] [ @@ -824,7 +857,7 @@ let webLogSettings a [ _class "btn btn-sm btn-secondary"; _href (relUrl app "admin/settings/rss/new/edit") ] [ raw "Add a New Custom Feed" ] - if feeds.Length = 0 then + if app.WebLog.Rss.CustomFeeds.Length = 0 then p [ _class "text-muted fst-italic text-center" ] [ raw "No custom feeds defined" ] else form [ _method "post"; _class "container g-0"; _hxTarget "body" ] [ @@ -834,31 +867,9 @@ let webLogSettings span [ _class "d-md-none" ] [ raw "Feed" ] span [ _class "d-none d-md-inline" ] [ raw "Source" ] ] - div [ _class $"col-12 col-md-6 d-none d-md-inline-block" ] [ raw "Relative Path" ] + div [ _class "col-12 col-md-6 d-none d-md-inline-block" ] [ raw "Relative Path" ] ] - for feed in feeds do - div [ _class "row mwl-table-detail" ] [ - div [ _class "col-12 col-md-6" ] [ - txt feed.Source - if feed.IsPodcast then - raw "   "; span [ _class "badge bg-primary" ] [ raw "PODCAST" ] - br [] - small [] [ - let feedUrl = relUrl app $"admin/settings/rss/{feed.Id}" - a [ _href (relUrl app feed.Path); _target "_blank" ] [ raw "View Feed" ] - actionSpacer - a [ _href $"{feedUrl}/edit" ] [ raw "Edit" ]; actionSpacer - a [ _href feedUrl; _hxDelete feedUrl; _class "text-danger" - _hxConfirm $"Are you sure you want to delete the custom RSS feed based on {feed.Source}? This action cannot be undone." ] [ - raw "Delete" - ] - ] - ] - div [ _class "col-12 col-md-6" ] [ - small [ _class "d-md-none" ] [ raw "Served at "; txt feed.Path ] - span [ _class "d-none d-md-inline" ] [ txt feed.Path ] - ] - ] + yield! app.WebLog.Rss.CustomFeeds |> List.map feedDetail ] ] ] -- 2.45.1 From 4301788344e6caa4afcc8767fb1409de677a31d4 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 16 Mar 2024 14:35:57 -0400 Subject: [PATCH 105/123] Bug fixes with new admin forms --- src/MyWebLog/Handlers/Page.fs | 4 ++-- src/MyWebLog/Handlers/Post.fs | 7 ++++--- src/MyWebLog/Views/Helpers.fs | 8 ++++---- src/MyWebLog/Views/Post.fs | 13 ++++++++----- src/MyWebLog/Views/WebLog.fs | 8 +++++--- src/admin-theme/wwwroot/admin.js | 4 +--- 6 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/MyWebLog/Handlers/Page.fs b/src/MyWebLog/Handlers/Page.fs index 2d23bc1..f616375 100644 --- a/src/MyWebLog/Handlers/Page.fs +++ b/src/MyWebLog/Handlers/Page.fs @@ -46,7 +46,7 @@ let delete pgId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> ta 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 "admin/pages" next ctx + return! all 1 next ctx } // GET /admin/page/{id}/permalinks @@ -96,7 +96,7 @@ let purgeRevisions pgId : HttpHandler = requireAccess Author >=> fun next ctx -> | Some pg -> do! data.Page.Update { pg with Revisions = [ List.head pg.Revisions ] } do! addMessage ctx { UserMessage.Success with Message = "Prior revisions purged successfully" } - return! redirectToGet $"admin/page/{pgId}/revisions" next ctx + return! editRevisions pgId next ctx | None -> return! Error.notFound next ctx } diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index 216cb8d..79bd624 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -273,6 +273,7 @@ let edit postId : HttpHandler = requireAccess Author >=> fun next ctx -> task { let! templates = templatesForTheme ctx "post" let model = EditPostModel.FromPost ctx.WebLog post let ratings = [ + { Name = ""; Value = "– Default –" } { Name = string Yes; Value = "Yes" } { Name = string No; Value = "No" } { Name = string Clean; Value = "Clean" } @@ -338,7 +339,7 @@ let purgeRevisions postId : HttpHandler = requireAccess Author >=> fun next ctx | Some post when canEdit post.AuthorId ctx -> do! data.Post.Update { post with Revisions = [ List.head post.Revisions ] } do! addMessage ctx { UserMessage.Success with Message = "Prior revisions purged successfully" } - return! redirectToGet $"admin/post/{postId}/revisions" next ctx + return! editRevisions postId next ctx | Some _ -> return! Error.notAuthorized next ctx | None -> return! Error.notFound next ctx } @@ -427,7 +428,7 @@ let editChapter (postId, index) : HttpHandler = requireAccess Author >=> fun nex // POST /admin/post/{id}/chapter/{idx} let saveChapter (postId, index) : HttpHandler = requireAccess Author >=> fun next ctx -> task { let data = ctx.Data - match! data.Post.FindById (PostId postId) ctx.WebLog.Id with + match! data.Post.FindFullById (PostId postId) ctx.WebLog.Id with | Some post when Option.isSome post.Episode && Option.isSome post.Episode.Value.Chapters @@ -447,7 +448,7 @@ let saveChapter (postId, index) : HttpHandler = requireAccess Author >=> fun nex do! addMessage ctx { UserMessage.Success with Message = "Chapter saved successfully" } return! Views.Post.chapterList form.AddAnother (ManageChaptersModel.Create updatedPost) - |> adminPage "Manage Chapters" true next ctx + |> adminBarePage "Manage Chapters" true next ctx with | ex -> return! Error.server ex.Message next ctx else return! Error.notFound next ctx diff --git a/src/MyWebLog/Views/Helpers.fs b/src/MyWebLog/Views/Helpers.fs index cf4d716..8907e37 100644 --- a/src/MyWebLog/Views/Helpers.fs +++ b/src/MyWebLog/Views/Helpers.fs @@ -132,8 +132,8 @@ let textField attrs name labelText value extra = inputField "text" attrs name labelText value extra /// Create a number input field -let numberField attrs name labelText (value: int) extra = - inputField "number" attrs name labelText (string value) extra +let numberField attrs name labelText value extra = + inputField "number" attrs name labelText value extra /// Create an e-mail input field let emailField attrs name labelText value extra = @@ -450,7 +450,7 @@ let managePermalinks (model: ManagePermalinksModel) app = [ div [ _id "permalinks"; _class "container g-0" ] [ yield! Array.mapi linkDetail model.Prior script [] [ - raw """document.addEventListener(\"DOMContentLoaded\", """ + raw """document.addEventListener("DOMContentLoaded", """ raw $"() => Admin.setPermalinkIndex({model.Prior.Length}))" ] ] @@ -514,7 +514,7 @@ let manageRevisions (model: ManageRevisionsModel) app = [ if model.Revisions.Length > 1 then div [ _class "row mb-3" ] [ div [ _class "col" ] [ - button [ _type "button"; _class "btn btn-sm btn-danger"; _hxDelete $"{revUrlBase}s/purge" + button [ _type "button"; _class "btn btn-sm btn-danger"; _hxDelete $"{revUrlBase}s" _hxConfirm "This will remove all revisions but the current one; are you sure this is what you wish to do?" ] [ raw "Delete All Prior Revisions" ] diff --git a/src/MyWebLog/Views/Post.fs b/src/MyWebLog/Views/Post.fs index 5e568fe..81b0719 100644 --- a/src/MyWebLog/Views/Post.fs +++ b/src/MyWebLog/Views/Post.fs @@ -325,7 +325,7 @@ let postEdit (model: EditPostModel) templates (ratings: MetaItem list) app = [ div [ _class "row pb-3" ] [ div [ _class "col" ] [ numberField [ _required ] (nameof model.Length) "Media Length (bytes)" - 0 (* TODO: string model.Length *) [ + (string model.Length) [ div [ _class "form-text" ] [ raw "TODO: derive from above file name" ] ] ] @@ -409,14 +409,16 @@ let postEdit (model: EditPostModel) templates (ratings: MetaItem list) app = [ ] div [ _class "row" ] [ div [ _class "col-12 col-md-8 pb-3" ] [ - textField [] (nameof model.TranscriptUrl) "Transcript URL" model.TranscriptUrl [ + textField [ _onkeyup "Admin.requireTranscriptType()" ] (nameof model.TranscriptUrl) + "Transcript URL" model.TranscriptUrl [ div [ _class "form-text" ] [ raw "Optional; relative URL served from this web log" ] ] ] div [ _class "col-12 col-md-4 pb-3" ] [ - textField [] (nameof model.TranscriptType) "Transcript MIME Type" + textField [ if model.TranscriptUrl <> "" then _required ] + (nameof model.TranscriptType) "Transcript MIME Type" model.TranscriptType [ div [ _class "form-text" ] [ raw "Required if transcript URL provided" ] ] @@ -436,7 +438,8 @@ let postEdit (model: EditPostModel) templates (ratings: MetaItem list) app = [ ] div [ _class "row pb-3" ] [ div [ _class "col col-md-4" ] [ - numberField [] (nameof model.SeasonNumber) "Season Number" model.SeasonNumber [ + numberField [] (nameof model.SeasonNumber) "Season Number" + (string model.SeasonNumber) [ div [ _class "form-text" ] [ raw "Optional" ] ] ] @@ -450,7 +453,7 @@ let postEdit (model: EditPostModel) templates (ratings: MetaItem list) app = [ div [ _class "row pb-3" ] [ div [ _class "col col-md-4" ] [ numberField [ _step "0.01" ] (nameof model.EpisodeNumber) "Episode Number" - 0 (* TODO: model.EpisodeNumber *) [ + model.EpisodeNumber [ div [ _class "form-text" ] [ raw "Optional; up to 2 decimal points" ] ] ] diff --git a/src/MyWebLog/Views/WebLog.fs b/src/MyWebLog/Views/WebLog.fs index a0f2cb6..98d64ca 100644 --- a/src/MyWebLog/Views/WebLog.fs +++ b/src/MyWebLog/Views/WebLog.fs @@ -281,7 +281,8 @@ let feedEdit (model: EditCustomFeedModel) (ratings: MetaItem list) (mediums: Met textField [] (nameof model.Subtitle) "Podcast Subtitle" model.Subtitle [] ] div [ _class "col-12 col-md-3 col-lg-2 pb-3" ] [ - numberField [ _required ] (nameof model.ItemsInFeed) "# Episodes" model.ItemsInFeed [] + numberField [ _required ] (nameof model.ItemsInFeed) "# Episodes" + (string model.ItemsInFeed) [] ] ] div [ _class "row" ] [ @@ -775,7 +776,7 @@ let webLogSettings ] div [ _class "col-12 col-md-4 col-xl-2 pb-3" ] [ numberField [ _required; _min "0"; _max "50" ] (nameof model.PostsPerPage) "Posts per Page" - model.PostsPerPage [] + (string model.PostsPerPage) [] ] ] div [ _class "row" ] [ @@ -827,7 +828,8 @@ let webLogSettings ] ] div [ _class "col-12 col-sm-6 col-md-4 col-xl-2 pb-3" ] [ - numberField [ _required; _min "0" ] (nameof rss.ItemsInFeed) "Items in Feed" rss.ItemsInFeed [ + numberField [ _required; _min "0" ] (nameof rss.ItemsInFeed) "Items in Feed" + (string rss.ItemsInFeed) [ span [ _class "form-text" ] [ raw "Set to “0” to use “Posts per Page” setting (" raw (string app.WebLog.PostsPerPage); raw ")" diff --git a/src/admin-theme/wwwroot/admin.js b/src/admin-theme/wwwroot/admin.js index 989065c..31baf7c 100644 --- a/src/admin-theme/wwwroot/admin.js +++ b/src/admin-theme/wwwroot/admin.js @@ -375,9 +375,7 @@ this.Admin = { */ showPreRenderedMessages() { [...document.querySelectorAll(".toast")].forEach(el => { - if (el.getAttribute("data-mwl-shown") === "true" && el.className.indexOf("hide") >= 0) { - document.removeChild(el) - } else { + if (el.getAttribute("data-mwl-shown") !== "true") { const toast = new bootstrap.Toast(el, el.getAttribute("data-bs-autohide") === "false" ? { autohide: false } : { delay: 6000, autohide: true }) -- 2.45.1 From 31d49d4b1a08566c0c5b85cf79799401bd7a471f Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 16 Mar 2024 22:00:42 -0400 Subject: [PATCH 106/123] Fix "add category" button (#40) --- src/MyWebLog/Handlers/Admin.fs | 2 +- src/MyWebLog/Views/WebLog.fs | 18 +++++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/MyWebLog/Handlers/Admin.fs b/src/MyWebLog/Handlers/Admin.fs index 6bca839..8d99828 100644 --- a/src/MyWebLog/Handlers/Admin.fs +++ b/src/MyWebLog/Handlers/Admin.fs @@ -96,7 +96,7 @@ module Category = // GET /admin/categories let all : HttpHandler = fun next ctx -> - adminPage "Categories" true next ctx Views.WebLog.categoryList + adminPage "Categories" true next ctx (Views.WebLog.categoryList (ctx.Request.Query.ContainsKey "new")) // GET /admin/category/{id}/edit let edit catId : HttpHandler = fun next ctx -> task { diff --git a/src/MyWebLog/Views/WebLog.fs b/src/MyWebLog/Views/WebLog.fs index 98d64ca..26bcc9a 100644 --- a/src/MyWebLog/Views/WebLog.fs +++ b/src/MyWebLog/Views/WebLog.fs @@ -51,7 +51,7 @@ let categoryEdit (model: EditCategoryModel) app = /// Category list page -let categoryList app = [ +let categoryList includeNew app = [ let catCol = "col-12 col-md-6 col-xl-5 col-xxl-4" let descCol = "col-12 col-md-6 col-xl-7 col-xxl-8" let categoryDetail (cat: DisplayCategory) = @@ -84,6 +84,8 @@ let categoryList app = [ match cat.Description with Some value -> raw value | None -> em [ _class "text-muted" ] [ raw "none" ] ] ] + let loadNew = + span [ _hxGet (relUrl app "admin/category/new/edit"); _hxTrigger HxTrigger.Load; _hxSwap HxSwap.OuterHtml ] [] h2 [ _class "my-3" ] [ raw app.PageTitle ] article [] [ @@ -92,11 +94,13 @@ let categoryList app = [ ] div [ _id "catList"; _class "container" ] [ if app.Categories.Length = 0 then - div [ _id "cat_new" ] [ - p [ _class "text-muted fst-italic text-center" ] [ - raw "This web log has no categories defined" + if includeNew then loadNew + else + div [ _id "cat_new" ] [ + p [ _class "text-muted fst-italic text-center" ] [ + raw "This web log has no categories defined" + ] ] - ] else div [ _class "container" ] [ div [ _class "row mwl-table-heading" ] [ @@ -108,7 +112,7 @@ let categoryList app = [ // don't think we need this... // _hxTarget "#catList"; _hxSwap $"{HxSwap.OuterHtml} show:window:top" antiCsrf app - div [ _class "row mwl-table-detail"; _id "cat_new" ] [] + div [ _class "row mwl-table-detail"; _id "cat_new" ] [ if includeNew then loadNew ] yield! app.Categories |> Seq.ofArray |> Seq.map categoryDetail |> List.ofSeq ] ] @@ -173,7 +177,7 @@ let dashboard (model: DashboardModel) app = [ a [ _href (relUrl app "admin/categories"); _class "btn btn-secondary me-2" ] [ raw "View All" ] - a [ _href (relUrl app "admin/category/new/edit"); _class "btn btn-secondary" ] [ + a [ _href (relUrl app "admin/categories?new"); _class "btn btn-secondary" ] [ raw "Add a New Category" ] ] -- 2.45.1 From f181f83aa55491272bc5940b84b1adc75b10d77d Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 17 Mar 2024 18:30:42 -0400 Subject: [PATCH 107/123] Misc admin theme tweaks --- src/MyWebLog/Handlers/Admin.fs | 5 ++++- src/MyWebLog/Handlers/Upload.fs | 11 +++-------- src/MyWebLog/Views/WebLog.fs | 29 ++++++++++++++++------------- 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/src/MyWebLog/Handlers/Admin.fs b/src/MyWebLog/Handlers/Admin.fs index 8d99828..a482305 100644 --- a/src/MyWebLog/Handlers/Admin.fs +++ b/src/MyWebLog/Handlers/Admin.fs @@ -3,6 +3,7 @@ module MyWebLog.Handlers.Admin open System.Threading.Tasks open Giraffe +open Giraffe.Htmx open MyWebLog open MyWebLog.ViewModels open NodaTime @@ -96,7 +97,9 @@ module Category = // GET /admin/categories let all : HttpHandler = fun next ctx -> - adminPage "Categories" true next ctx (Views.WebLog.categoryList (ctx.Request.Query.ContainsKey "new")) + let response = fun next ctx -> + adminPage "Categories" true next ctx (Views.WebLog.categoryList (ctx.Request.Query.ContainsKey "new")) + (withHxPushUrl (ctx.WebLog.RelativeUrl (Permalink "admin/categories")) >=> response) next ctx // GET /admin/category/{id}/edit let edit catId : HttpHandler = fun next ctx -> task { diff --git a/src/MyWebLog/Handlers/Upload.fs b/src/MyWebLog/Handlers/Upload.fs index a52b037..c992eda 100644 --- a/src/MyWebLog/Handlers/Upload.fs +++ b/src/MyWebLog/Handlers/Upload.fs @@ -127,11 +127,6 @@ let list : HttpHandler = requireAccess Author >=> fun next ctx -> task { let showNew : HttpHandler = requireAccess Author >=> fun next ctx -> adminPage "Upload a File" true next ctx Views.WebLog.uploadNew - -/// Redirect to the upload list -let showUploads : HttpHandler = - redirectToGet "admin/uploads" - // POST /admin/upload/save let save : HttpHandler = requireAccess Author >=> fun next ctx -> task { if ctx.Request.HasFormContentType && ctx.Request.Form.Files.Count > 0 then @@ -162,7 +157,7 @@ let save : HttpHandler = requireAccess Author >=> fun next ctx -> task { do! upload.CopyToAsync stream do! addMessage ctx { UserMessage.Success with Message = $"File uploaded to {form.Destination} successfully" } - return! showUploads next ctx + return! redirectToGet "admin/uploads" next ctx else return! RequestErrors.BAD_REQUEST "Bad request; no file present" next ctx } @@ -172,7 +167,7 @@ let deleteFromDb upId : HttpHandler = fun next ctx -> task { match! ctx.Data.Upload.Delete (UploadId upId) ctx.WebLog.Id with | Ok fileName -> do! addMessage ctx { UserMessage.Success with Message = $"{fileName} deleted successfully" } - return! showUploads next ctx + return! list next ctx | Error _ -> return! Error.notFound next ctx } @@ -195,6 +190,6 @@ let deleteFromDisk urlParts : HttpHandler = fun next ctx -> task { File.Delete path removeEmptyDirectories ctx.WebLog filePath do! addMessage ctx { UserMessage.Success with Message = $"{filePath} deleted successfully" } - return! showUploads next ctx + return! list next ctx else return! Error.notFound next ctx } diff --git a/src/MyWebLog/Views/WebLog.fs b/src/MyWebLog/Views/WebLog.fs index 26bcc9a..793826f 100644 --- a/src/MyWebLog/Views/WebLog.fs +++ b/src/MyWebLog/Views/WebLog.fs @@ -29,7 +29,7 @@ let categoryEdit (model: EditCategoryModel) app = |> Seq.map (fun c -> let parents = c.ParentNames - |> Array.map (fun it -> $"{it}   » ") + |> Array.map (fun it -> $"{it} ⟩ ") |> String.concat "" { Name = c.Id; Value = $"{parents}{c.Name}" }) |> Seq.append [ { Name = ""; Value = "– None –" } ] @@ -74,7 +74,7 @@ let categoryList includeNew app = [ _hxSwap $"{HxSwap.InnerHtml} show:#cat_{cat.Id}:top" ] [ raw "Edit" ]; actionSpacer - a [ _href catUrl; _hxDelete catUrl; _class "text-danger" + a [ _href catUrl; _hxDelete catUrl; _hxTarget "body"; _class "text-danger" _hxConfirm $"Are you sure you want to delete the category “{cat.Name}”? This action cannot be undone." ] [ raw "Delete" ] @@ -109,8 +109,6 @@ let categoryList includeNew app = [ ] ] form [ _method "post"; _class "container" ] [ - // don't think we need this... - // _hxTarget "#catList"; _hxSwap $"{HxSwap.OuterHtml} show:window:top" antiCsrf app div [ _class "row mwl-table-detail"; _id "cat_new" ] [ if includeNew then loadNew ] yield! app.Categories |> Seq.ofArray |> Seq.map categoryDetail |> List.ofSeq @@ -632,7 +630,10 @@ let uploadList (model: DisplayUpload seq) app = [ ] div [ _class "col-3" ] [ raw upload.Path ] div [ _class "col-3" ] [ - raw (match upload.UpdatedOn with Some updated -> updated.ToString "yyyy-MM-dd/HH:mm" | None -> "--") + match upload.UpdatedOn with + | Some updated -> updated.ToString("yyyy-MM-dd/h:mmtt").ToLowerInvariant() + | None -> "--" + |> raw ] ] @@ -680,14 +681,16 @@ let uploadNew app = [ ] ] div [ _class "col-12 col-md-6 pb-3 d-flex align-self-center justify-content-around" ] [ - raw "Destination"; br [] - div [ _class "btn-group"; _roleGroup; _ariaLabel "Upload destination button group" ] [ - input [ _type "radio"; _name "Destination"; _id "destination_db"; _class "btn-check" - _value (string Database); if app.WebLog.Uploads = Database then _checked ] - label [ _class "btn btn-outline-primary"; _for "destination_db" ] [ raw (string Database) ] - input [ _type "radio"; _name "Destination"; _id "destination_disk"; _class "btn-check" - _value (string Disk); if app.WebLog.Uploads= Disk then _checked ] - label [ _class "btn btn-outline-secondary"; _for "destination_disk" ] [ raw "Disk" ] + div [ _class "text-center" ] [ + raw "Destination"; br [] + div [ _class "btn-group"; _roleGroup; _ariaLabel "Upload destination button group" ] [ + input [ _type "radio"; _name "Destination"; _id "destination_db"; _class "btn-check" + _value (string Database); if app.WebLog.Uploads = Database then _checked ] + label [ _class "btn btn-outline-primary"; _for "destination_db" ] [ raw (string Database) ] + input [ _type "radio"; _name "Destination"; _id "destination_disk"; _class "btn-check" + _value (string Disk); if app.WebLog.Uploads= Disk then _checked ] + label [ _class "btn btn-outline-secondary"; _for "destination_disk" ] [ raw "Disk" ] + ] ] ] ] -- 2.45.1 From ac332a67977091c777fb7f00f51be0f485fd27b3 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Tue, 19 Mar 2024 22:43:50 -0400 Subject: [PATCH 108/123] Fix Docker builds (#38) --- src/Dockerfile | 1 + src/sqlite.Dockerfile | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Dockerfile b/src/Dockerfile index 48fd7e8..cb3fbd5 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -5,6 +5,7 @@ COPY ./Directory.Build.props ./ COPY ./MyWebLog/MyWebLog.fsproj ./MyWebLog/ COPY ./MyWebLog.Data/MyWebLog.Data.fsproj ./MyWebLog.Data/ COPY ./MyWebLog.Domain/MyWebLog.Domain.fsproj ./MyWebLog.Domain/ +COPY ./MyWebLog.Tests/MyWebLog.Tests.fsproj ./MyWebLog.Tests/ RUN dotnet restore COPY . ./ diff --git a/src/sqlite.Dockerfile b/src/sqlite.Dockerfile index 68b895f..fe57963 100644 --- a/src/sqlite.Dockerfile +++ b/src/sqlite.Dockerfile @@ -5,6 +5,7 @@ COPY ./Directory.Build.props ./ COPY ./MyWebLog/MyWebLog.fsproj ./MyWebLog/ COPY ./MyWebLog.Data/MyWebLog.Data.fsproj ./MyWebLog.Data/ COPY ./MyWebLog.Domain/MyWebLog.Domain.fsproj ./MyWebLog.Domain/ +COPY ./MyWebLog.Tests/MyWebLog.Tests.fsproj ./MyWebLog.Tests/ RUN dotnet restore COPY . ./ -- 2.45.1 From dce80fdddcf55c5ccc313b43cf7b4f99b701ceb9 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Fri, 22 Mar 2024 21:32:35 -0400 Subject: [PATCH 109/123] Fix theme zipping in Docker (#38) - Default to port 80 --- src/.dockerignore | 2 ++ src/Dockerfile | 11 ++++++----- src/MyWebLog/appsettings.json | 7 +++++++ src/sqlite.Dockerfile | 11 ++++++----- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/.dockerignore b/src/.dockerignore index f181d71..147d38c 100644 --- a/src/.dockerignore +++ b/src/.dockerignore @@ -1,2 +1,4 @@ **/bin **/obj +**/*.db +**/appsettings.*.json diff --git a/src/Dockerfile b/src/Dockerfile index cb3fbd5..89387d5 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -14,11 +14,12 @@ RUN dotnet publish -f net8.0 -c Release -r linux-x64 --no-self-contained -p:Publ FROM alpine AS theme RUN apk add --no-cache zip -WORKDIR /themes -COPY ./default-theme ./default-theme/ -RUN zip default-theme.zip ./default-theme/* -COPY ./admin-theme ./admin-theme/ -RUN zip admin-theme.zip ./admin-theme/* +WORKDIR /themes/default-theme +COPY ./default-theme ./ +RUN zip -r ../default-theme.zip ./* +WORKDIR /themes/admin-theme +COPY ./admin-theme ./ +RUN zip -r ../admin-theme.zip ./* FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine as final WORKDIR /app diff --git a/src/MyWebLog/appsettings.json b/src/MyWebLog/appsettings.json index 102b5a3..d4c8a33 100644 --- a/src/MyWebLog/appsettings.json +++ b/src/MyWebLog/appsettings.json @@ -4,5 +4,12 @@ "LogLevel": { "MyWebLog.Handlers": "Information" } + }, + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://0.0.0.0:80" + } + } } } diff --git a/src/sqlite.Dockerfile b/src/sqlite.Dockerfile index fe57963..35929b7 100644 --- a/src/sqlite.Dockerfile +++ b/src/sqlite.Dockerfile @@ -14,11 +14,12 @@ RUN dotnet publish -f net8.0 -c Release -r linux-x64 FROM alpine AS theme RUN apk add --no-cache zip -WORKDIR /themes -COPY ./default-theme ./default-theme/ -RUN zip default-theme.zip ./default-theme/* -COPY ./admin-theme ./admin-theme/ -RUN zip admin-theme.zip ./admin-theme/* +WORKDIR /themes/default-theme +COPY ./default-theme ./ +RUN zip -r ../default-theme.zip ./* +WORKDIR /themes/admin-theme +COPY ./admin-theme ./ +RUN zip -r ../admin-theme.zip ./* FROM mcr.microsoft.com/dotnet/aspnet:8.0 as final WORKDIR /app -- 2.45.1 From c20628bb9c0765c66f4dedaa8657cedd601b7245 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 23 Mar 2024 16:46:36 -0400 Subject: [PATCH 110/123] Update deps before release --- src/MyWebLog.Data/MyWebLog.Data.fsproj | 5 +++-- src/MyWebLog.Domain/MyWebLog.Domain.fsproj | 7 ++++--- src/MyWebLog.Tests/MyWebLog.Tests.fsproj | 3 ++- src/MyWebLog/MyWebLog.fsproj | 11 ++++++----- src/MyWebLog/Program.fs | 2 +- 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/MyWebLog.Data/MyWebLog.Data.fsproj b/src/MyWebLog.Data/MyWebLog.Data.fsproj index 804465b..a3d9a77 100644 --- a/src/MyWebLog.Data/MyWebLog.Data.fsproj +++ b/src/MyWebLog.Data/MyWebLog.Data.fsproj @@ -7,14 +7,15 @@ - + - + + diff --git a/src/MyWebLog.Domain/MyWebLog.Domain.fsproj b/src/MyWebLog.Domain/MyWebLog.Domain.fsproj index d86aa8a..fa34b80 100644 --- a/src/MyWebLog.Domain/MyWebLog.Domain.fsproj +++ b/src/MyWebLog.Domain/MyWebLog.Domain.fsproj @@ -7,10 +7,11 @@ - - + + - + + diff --git a/src/MyWebLog.Tests/MyWebLog.Tests.fsproj b/src/MyWebLog.Tests/MyWebLog.Tests.fsproj index 35b206e..353d71d 100644 --- a/src/MyWebLog.Tests/MyWebLog.Tests.fsproj +++ b/src/MyWebLog.Tests/MyWebLog.Tests.fsproj @@ -26,8 +26,9 @@ - + + diff --git a/src/MyWebLog/MyWebLog.fsproj b/src/MyWebLog/MyWebLog.fsproj index 105c91b..227afcb 100644 --- a/src/MyWebLog/MyWebLog.fsproj +++ b/src/MyWebLog/MyWebLog.fsproj @@ -31,12 +31,13 @@ - - - - + + + + - + + diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index 293fb17..32abcdc 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -131,7 +131,7 @@ open Microsoft.AspNetCore.Authentication.Cookies open Microsoft.AspNetCore.Builder open Microsoft.AspNetCore.HttpOverrides open Microsoft.Extensions.Caching.Distributed -open NeoSmart.Caching.Sqlite +open NeoSmart.Caching.Sqlite.AspNetCore open RethinkDB.DistributedCache [] -- 2.45.1 From 895b2a72e737b57bf83a290749d4b401050a0fdf Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 23 Mar 2024 17:22:55 -0400 Subject: [PATCH 111/123] Update build files --- build.fs | 2 +- build.fsproj | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/build.fs b/build.fs index e841ab6..9e17e7f 100644 --- a/build.fs +++ b/build.fs @@ -33,7 +33,7 @@ let zipTheme (name : string) (_ : TargetParameter) = |> Zip.zipSpec $"{releasePath}/{name}-theme.zip" /// Frameworks supported by this build -let frameworks = [ "net6.0"; "net7.0" ] +let frameworks = [ "net6.0"; "net7.0"; "net8.0" ] /// Publish the project for the given runtime ID let publishFor rid (_ : TargetParameter) = diff --git a/build.fsproj b/build.fsproj index 449dd30..5821d28 100644 --- a/build.fsproj +++ b/build.fsproj @@ -2,7 +2,7 @@ Exe - net7.0 + net8.0 @@ -10,11 +10,11 @@ - - - - - + + + + + -- 2.45.1 From 2fe104f0c19bf449c6d5b6da8e9ccdfec777ad63 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 23 Mar 2024 18:50:33 -0400 Subject: [PATCH 112/123] Add GitHub Actions YAML --- .github/workflows/ci.yml | 52 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e42df14 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: Continuous Integration +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + workflow_dispatch: +jobs: + build_and_test: + name: Build / Test + + runs-on: ubuntu-latest + + strategy: + matrix: + dotnet-version: + - "6.0" +# - "7.0" +# - "8.0" + + services: + postgres: + image: postgres:latest + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + rethink: + image: rethinkdb:latest + ports: + - 28015:28015 + - 29015:29015 + + steps: + - name: Check Out Code + uses: actions/checkout@v4 + - name: Setup .NET Core SDK + uses: actions/setup-dotnet@v4.0.0 + with: + dotnet-version: 8.x + - name: Restore dependencies + run: dotnet restore src/MyWebLog.sln + - name: Build (${{ matrix.dotnet-version }}) + run: dotnet build src/MyWebLog.sln -f net${{ matrix.dotnet-version }} + - name: Test (${{ matrix.dotnet-version }}) + run: dotnet run --project src/MyWebLog.Tests/MyWebLog.Tests.fsproj -f net${{ matrix.dotnet-version }} + \ No newline at end of file -- 2.45.1 From add6a8086b1702829171946eb08d2f67b4dec165 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 23 Mar 2024 19:01:35 -0400 Subject: [PATCH 113/123] Add Rethink URI, run tests after cd --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e42df14..8714119 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,8 @@ on: pull_request: branches: [ "main" ] workflow_dispatch: +env: + MWL_TEST_RETHINK_URI: rethinkdb://localhost/mwl_test jobs: build_and_test: name: Build / Test @@ -48,5 +50,5 @@ jobs: - name: Build (${{ matrix.dotnet-version }}) run: dotnet build src/MyWebLog.sln -f net${{ matrix.dotnet-version }} - name: Test (${{ matrix.dotnet-version }}) - run: dotnet run --project src/MyWebLog.Tests/MyWebLog.Tests.fsproj -f net${{ matrix.dotnet-version }} + run: cd src/MyWebLog.Tests; dotnet run -f net${{ matrix.dotnet-version }} \ No newline at end of file -- 2.45.1 From 5c7e897532f6c8679c5a4b2f3a7cbb01e8a284ac Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 23 Mar 2024 19:17:30 -0400 Subject: [PATCH 114/123] Enable .NET 7 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8714119..557e284 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: matrix: dotnet-version: - "6.0" -# - "7.0" + - "7.0" # - "8.0" services: -- 2.45.1 From eaa1824ec314f3be5b4aaf0e768f0cf228de8307 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 23 Mar 2024 19:20:31 -0400 Subject: [PATCH 115/123] Enable .NET 8 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 557e284..70d4906 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: dotnet-version: - "6.0" - "7.0" -# - "8.0" + - "8.0" services: postgres: -- 2.45.1 From ad49c9536b8d09eb3837040e10918eec1a8effb0 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 23 Mar 2024 19:36:22 -0400 Subject: [PATCH 116/123] Add release packaging --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70d4906..494a1fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,6 @@ env: jobs: build_and_test: name: Build / Test - runs-on: ubuntu-latest strategy: @@ -51,4 +50,25 @@ jobs: run: dotnet build src/MyWebLog.sln -f net${{ matrix.dotnet-version }} - name: Test (${{ matrix.dotnet-version }}) run: cd src/MyWebLog.Tests; dotnet run -f net${{ matrix.dotnet-version }} - \ No newline at end of file + + publish: + name: Publish .zip / .bz2 Packages + runs-on: ubuntu-latest + needs: build_and_test + + steps: + - name: Check Out Code + uses: actions/checkout@v4 + - name: Setup .NET Core SDK + uses: actions/setup-dotnet@v4.0.0 + with: + dotnet-version: 8.x + - name: Create Release Packages + run: dotnet run build.fsproj + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + name: release-packages + path: | + release/*x64.zip + release/*.bz2 -- 2.45.1 From 5ba05bdb919dbda4829a0eee7eb6b090d4dacb81 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 23 Mar 2024 19:39:28 -0400 Subject: [PATCH 117/123] Add CI flag to build --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 494a1fa..7715e21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,7 @@ jobs: with: dotnet-version: 8.x - name: Create Release Packages - run: dotnet run build.fsproj + run: dotnet run build.fsproj CI - name: Upload Artifacts uses: actions/upload-artifact@v4 with: -- 2.45.1 From aa2707d8a57d248249fed5375b18c34180d0dcbb Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 23 Mar 2024 20:32:07 -0400 Subject: [PATCH 118/123] Use matrix for publish job --- .github/workflows/ci.yml | 58 ++++++++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7715e21..84ab515 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,23 +52,47 @@ jobs: run: cd src/MyWebLog.Tests; dotnet run -f net${{ matrix.dotnet-version }} publish: - name: Publish .zip / .bz2 Packages + name: Publish Packages runs-on: ubuntu-latest needs: build_and_test - + + strategy: + matrix: + ver: + - "net6.0" + - "net7.0" + - "net8.0" + os: + - "linux-x64" + - "win-x64" + include: + - os: "linux-x64" + bz2: true + - os: "win-x64" + zip: true steps: - - name: Check Out Code - uses: actions/checkout@v4 - - name: Setup .NET Core SDK - uses: actions/setup-dotnet@v4.0.0 - with: - dotnet-version: 8.x - - name: Create Release Packages - run: dotnet run build.fsproj CI - - name: Upload Artifacts - uses: actions/upload-artifact@v4 - with: - name: release-packages - path: | - release/*x64.zip - release/*.bz2 + - name: Check Out Code + uses: actions/checkout@v4 + - name: Setup .NET Core SDK + uses: actions/setup-dotnet@v4.0.0 + with: + dotnet-version: 8.x + - name: Publish (Release) + run: dotnet publish -c Release -f ${{ matrix.ver }} -r ${{ matrix.os }} src/MyWebLog/MyWebLog.fsproj + - name: Zip Admin Theme + run: cd src/admin-theme; zip -r ../MyWebLog/bin/Release/${{ matrix.ver }}/${{ matrix.os }}/publish/admin-theme.zip *; cd ../.. + - name: Zip Default Theme + run: cd src/default-theme; zip -r ../MyWebLog/bin/Release/${{ matrix.ver }}/${{ matrix.os }}/publish/default-theme.zip *; cd ../.. + - if: ${{ matrix.bz2 }} + name: Create .tar.bz2 Archive + run: tar cfj myWebLog-${{ matrix.ver }}-${{ matrix.os }}.tar.bz2 -C src/MyWebLog/bin/Release/${{ matrix.ver }}/${{ matrix.os }}/publish . + - if: ${{ matrix.zip }} + name: Create .zip Archive + run: cd src/MyWebLog/bin/Release/${{ matrix.ver }}/${{ matrix.os }}/publish; zip -r myWebLog-${{ matrix.ver }}-${{ matrix.os }}.zip *; cp myWeb*.zip ../../../../../../..; cd ../../../../../../.. + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + name: release-packages + path: | + *x64.zip + *.bz2 -- 2.45.1 From c9a097a9ea78ad63ec85643eafab7dd53f0625df Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 23 Mar 2024 20:35:28 -0400 Subject: [PATCH 119/123] Fix indentation --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84ab515..84bc0d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,6 @@ jobs: uses: actions/upload-artifact@v4 with: name: release-packages - path: | - *x64.zip - *.bz2 + path: | + *x64.zip + *.bz2 -- 2.45.1 From d28b18a8d2da0caecf305cad27d9879a78b6b962 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 23 Mar 2024 20:41:59 -0400 Subject: [PATCH 120/123] Remove name from artifact upload --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84bc0d5..cc2ddc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,6 @@ jobs: - name: Upload Artifacts uses: actions/upload-artifact@v4 with: - name: release-packages path: | *x64.zip *.bz2 -- 2.45.1 From 5fc3d11454d7cf0c491011fdf7502cd68efac837 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 23 Mar 2024 20:46:47 -0400 Subject: [PATCH 121/123] Include matrix vars in artifact name --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc2ddc9..a40ae15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,6 +92,7 @@ jobs: - name: Upload Artifacts uses: actions/upload-artifact@v4 with: + name: package-${{ matrix.ver }}-${{ matrix.os }} path: | *x64.zip *.bz2 -- 2.45.1 From 2439d017efaee2ae3a3a3ea8d71c1402f9272529 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 23 Mar 2024 21:06:49 -0400 Subject: [PATCH 122/123] Fix indents in CI YAML --- .github/workflows/ci.yml | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a40ae15..92a79d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,11 @@ name: Continuous Integration on: push: - branches: [ "main" ] + branches: + - main pull_request: - branches: [ "main" ] + branches: + - main workflow_dispatch: env: MWL_TEST_RETHINK_URI: rethinkdb://localhost/mwl_test @@ -15,9 +17,9 @@ jobs: strategy: matrix: dotnet-version: - - "6.0" - - "7.0" - - "8.0" + - "6.0" + - "7.0" + - "8.0" services: postgres: @@ -34,22 +36,21 @@ jobs: rethink: image: rethinkdb:latest ports: - - 28015:28015 - - 29015:29015 + - 28015:28015 steps: - - name: Check Out Code - uses: actions/checkout@v4 - - name: Setup .NET Core SDK - uses: actions/setup-dotnet@v4.0.0 - with: - dotnet-version: 8.x - - name: Restore dependencies - run: dotnet restore src/MyWebLog.sln - - name: Build (${{ matrix.dotnet-version }}) - run: dotnet build src/MyWebLog.sln -f net${{ matrix.dotnet-version }} - - name: Test (${{ matrix.dotnet-version }}) - run: cd src/MyWebLog.Tests; dotnet run -f net${{ matrix.dotnet-version }} + - name: Check Out Code + uses: actions/checkout@v4 + - name: Setup .NET Core SDK + uses: actions/setup-dotnet@v4.0.0 + with: + dotnet-version: 8.x + - name: Restore dependencies + run: dotnet restore src/MyWebLog.sln + - name: Build (${{ matrix.dotnet-version }}) + run: dotnet build src/MyWebLog.sln -f net${{ matrix.dotnet-version }} + - name: Test (${{ matrix.dotnet-version }}) + run: cd src/MyWebLog.Tests; dotnet run -f net${{ matrix.dotnet-version }} publish: name: Publish Packages -- 2.45.1 From d8024ac6f452ddd5091f7170b554799975923962 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Mon, 25 Mar 2024 22:00:04 -0400 Subject: [PATCH 123/123] Add chapter ref to feed (#6) --- src/MyWebLog/Handlers/Feed.fs | 25 ++++++++++++++++--------- src/MyWebLog/Handlers/Helpers.fs | 3 +++ src/MyWebLog/Handlers/Post.fs | 2 +- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/MyWebLog/Handlers/Feed.fs b/src/MyWebLog/Handlers/Feed.fs index 8c55dbd..25f055f 100644 --- a/src/MyWebLog/Handlers/Feed.fs +++ b/src/MyWebLog/Handlers/Feed.fs @@ -163,17 +163,24 @@ let private addEpisode (webLog: WebLog) (podcast: PodcastOptions) (episode: Epis episode.Subtitle |> Option.iter (fun it -> item.ElementExtensions.Add("subtitle", Namespace.iTunes, it)) episode.FormatDuration() |> Option.iter (fun it -> item.ElementExtensions.Add("duration", Namespace.iTunes, it)) - match episode.ChapterFile with - | Some chapters -> - let url = toAbsolute webLog chapters - let typ = - match episode.ChapterType with - | Some mime -> Some mime - | None when chapters.EndsWith ".json" -> Some "application/json+chapters" - | None -> None + let chapterUrl, chapterMimeType = + match episode.Chapters, episode.ChapterFile with + | Some _, _ -> + Some $"{webLog.AbsoluteUrl post.Permalink}?chapters", Some JSON_CHAPTERS + | None, Some chapters -> + let typ = + match episode.ChapterType with + | Some mime -> Some mime + | None when chapters.EndsWith ".json" -> Some JSON_CHAPTERS + | None -> None + Some (toAbsolute webLog chapters), typ + | None, None -> None, None + + match chapterUrl with + | Some url -> let elt = xmlDoc.CreateElement("podcast", "chapters", Namespace.podcast) elt.SetAttribute("url", url) - typ |> Option.iter (fun it -> elt.SetAttribute("type", it)) + chapterMimeType |> Option.iter (fun it -> elt.SetAttribute("type", it)) item.ElementExtensions.Add elt | None -> () diff --git a/src/MyWebLog/Handlers/Helpers.fs b/src/MyWebLog/Handlers/Helpers.fs index 13a0501..1a26bea 100644 --- a/src/MyWebLog/Handlers/Helpers.fs +++ b/src/MyWebLog/Handlers/Helpers.fs @@ -268,6 +268,9 @@ let redirectToGet url : HttpHandler = fun _ ctx -> task { return! redirectTo false (ctx.WebLog.RelativeUrl(Permalink url)) earlyReturn ctx } +/// The MIME type for podcast episode JSON chapters +let JSON_CHAPTERS = "application/json+chapters" + /// Handlers for error conditions module Error = diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index 79bd624..b1ae54a 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -238,7 +238,7 @@ let chapters (post: Post) : HttpHandler = fun next ctx -> jsonFile["fileName"] <- absoluteUrl ep.Media ctx if defaultArg ep.ChapterWaypoints false then jsonFile["waypoints"] <- true jsonFile["chapters"] <- chapterData - json jsonFile next ctx + (setContentType JSON_CHAPTERS >=> json jsonFile) next ctx | None -> match ep.ChapterFile with | Some file -> redirectTo true file next ctx -- 2.45.1