66 Commits
Author SHA1 Message Date
danieljsummers 0c2e2e9265 Fix source dropdown; disable auto-compl on e-mail 2026-09-19 14:11:36 -04:00
danieljsummers e17e5b79f0 Add person add/edit/delete in settings (#17) 2026-08-23 15:26:18 -04:00
danieljsummers 5c7d34ffc7 Add person data access, integ tests, backup/restore (#17) 2026-08-02 22:09:03 -04:00
danieljsummers d9e7c771ba WIP on person table, podcast/ep assignment (#17) 2026-08-02 17:33:38 -04:00
danieljsummers 05a810dfc6 Finish person role (#17) 2026-08-02 14:31:56 -04:00
danieljsummers be4f621d42 Move podcast types to own file; more WIP on role (#17) 2026-08-02 14:02:47 -04:00
danieljsummers e7261f6019 Add group, WIP on role (#17) 2026-08-01 22:45:18 -04:00
danieljsummers 2241742ab8 Remove server header from responses 2026-08-01 16:28:38 -04:00
danieljsummers a380c7ea70 Fix page pagination on RethinkDB 2026-07-24 22:13:03 -04:00
danieljsummers 2f22a1a2d7 Update doc lib, removed pinned SQLite 2026-07-21 07:59:36 -04:00
danieljsummers c764eed81d Restore include_template tag (#47)
This works with Fluid, and there is no reason to mandate a change;
we will document the different behavior with include
2026-07-19 15:35:09 -04:00
danieljsummers 4a7481a98a Strip .liquid from name before include lookup (#47) 2026-07-19 15:14:41 -04:00
danieljsummers 2eb0153e46 Pin SQLite library to non-vuln version 2026-07-19 12:58:28 -04:00
danieljsummers 3a19952f39 Move redirect rule to 2 row, remove no-wrap (#55)
- Fix redirect rule handling with endpoint routing (#59)
- Make subtitle non-nullable string (#47)
2026-07-18 12:50:27 -04:00
danieljsummers 816a156d79 Fix default theme page title (#47) 2026-07-17 23:02:00 -04:00
danieljsummers 84025a5eb2 Change log off to POST (#61) 2026-07-17 22:49:38 -04:00
danieljsummers 03dc289991 Use favicon if OG image URL is not specified (#52) 2026-07-17 22:23:39 -04:00
danieljsummers abfae6786d JS tweaks for showing messages (#60) 2026-07-17 21:19:32 -04:00
danieljsummers 76f0506de7 WIP on htmx 4; change target from window to body (#60) 2026-07-14 19:55:55 -04:00
danieljsummers e8ee236c1e WIP on htmx 4; seeing console errors (#60) 2026-07-13 21:30:42 -04:00
danieljsummers 0fcb40fbdd WIP on htmx 4; user messages now work (#60) 2026-07-12 14:08:32 -04:00
danieljsummers d31b508359 WIP on htmx 4 integration (#60) 2026-07-11 21:33:07 -04:00
danieljsummers 32372a3f9f Remove DotLiquid (#47) 2026-07-11 17:58:00 -04:00
danieljsummers b3eab09576 Add Fluid where filter, fix slug (#47)
- Fix issue with path base and logon redirects
2026-07-07 22:57:57 -04:00
danieljsummers 8e0b2df870 Add true/false to auto-htmx converter (#59)
- This will allow backups to be restored, converting the value as part of the restoration
2026-07-07 20:26:34 -04:00
danieljsummers edb376c06c Remove htmx script from view context (#59) 2026-07-06 08:32:38 -04:00
danieljsummers be6251b6ec Add log for auto-htmx migration step (#59) 2026-07-06 08:24:24 -04:00
danieljsummers fcd02ecbec Implement htmax as auto-htmx selection (#59)
- Move program support items to MyWebLog namespace
- Fix parameter ordering in some tests
2026-07-05 21:28:58 -04:00
danieljsummers 9d2277ab32 WIP on adding htmax option (#59) 2026-07-05 17:54:36 -04:00
danieljsummers 7c588662d2 Update SDK; htmx serving now works (#59) 2026-07-04 20:27:39 -04:00
danieljsummers 84444e4789 Edit formatting for handlers 2026-07-03 19:23:45 -04:00
danieljsummers 0de1bff190 More WIP to get htmx served; still no luck (#59) 2026-07-03 16:59:41 -04:00
danieljsummers 38a06b53f4 Convert all but regex routes to endpoints (#59) 2026-07-03 12:53:24 -04:00
danieljsummers be094fb07e WIP on endpoint routing conversion (#59) 2026-07-01 23:43:48 -04:00
danieljsummers cb9ec52a7d WIP on pulling embedded htmx 2026-06-28 20:05:49 -04:00
danieljsummers 2755b250e4 Upgrade to htmx 4 (#58)
- There may be more htmx work to do, but the package is updated
2026-06-20 16:15:49 -04:00
danieljsummers 1b7010bff8 Update all deps but Giraffe.Htmx; migrate sln to slnx (#58) 2026-06-20 15:40:23 -04:00
danieljsummers f9936bf6ae Implement central package management (#58) 2026-06-20 15:27:31 -04:00
danieljsummers 5635e2be32 Add .NET 10 target (#58) 2026-06-20 15:14:13 -04:00
danieljsummers 7374440621 WIP on OpenGraph form; first cut done (#52) 2025-08-03 22:03:07 -04:00
danieljsummers 60a22747ac WIP on OpenGraph form; add media files (#52) 2025-08-03 21:08:34 -04:00
danieljsummers 9b295263f9 WIP on OpenGraph form; items field complete (#52) 2025-08-03 17:01:02 -04:00
danieljsummers bc1d17d916 WIP on OpenGraph edit form (#52) 2025-08-02 20:57:29 -04:00
danieljsummers cba1bbfa28 Support relative URLs in OpenGraph properties (#52) 2025-08-01 22:16:19 -04:00
danieljsummers 8b190a6c23 Add tests for OpenGraph view model properties 2025-07-26 20:33:30 -04:00
danieljsummers e33966b3df WIP on OpenGraph post/page model (#52)
- Removed SecureUrl prop; will generate if URL starts with https:
2025-07-15 23:38:02 -04:00
danieljsummers 3ad6b5a521 Move OpenGraph property generation to models (#52)
- Add auto-OpenGraph field to web log
- Only generate properties for posts/pages without them if this flag is set
- Set flag to yes on v3 database migration
- Add JSON converter for OpenGraph type
- Add tests for models
2025-07-10 23:03:16 -04:00
danieljsummers 210dd41cee Add OG types to page/post, add rendering in page head (#52) 2025-07-09 22:04:37 -04:00
danieljsummers fa4e1d327a WIP on OpenGraph types (#52) 2025-07-06 22:09:36 -04:00
danieljsummers c19f92889e Return client refresh from redir rule if htmx (#54) 2025-07-05 18:32:46 -04:00
danieljsummers 1f7d415868 Add model alias for payload (#47)
- Update post/page canonical URLs for Fluid
2025-07-05 16:48:15 -04:00
danieljsummers ba5e27e011 Simplify catch-all route; bump admin theme version 2025-07-05 13:40:06 -04:00
danieljsummers 161a61823f Remove CSRF from admin/upload delete endpoints 2025-07-05 12:07:58 -04:00
danieljsummers d1840f63e5 Remove CSRF from page, post, category delete routes (#56)
- Chapter delete swap target needs work
2025-07-04 22:24:32 -04:00
danieljsummers 87fbb1a8c7 Vendor htmx 2.0.6 (#57) 2025-07-04 18:18:13 -04:00
danieljsummers e8953d6072 Update deps; fix SQLite web log delete query 2025-07-04 11:06:34 -04:00
danieljsummers dc30716b83 Update deps; WIP on comments 2025-01-23 22:11:12 -05:00
danieljsummers 88841fd3f8 Update to .NET 8/9; minor tweaks 2024-12-23 21:16:34 -05:00
danieljsummers 870f87cb17 Update for v4-rc5 of doc library 2024-09-18 19:54:45 -04:00
danieljsummers 0032d15c0a Update for doc lib v4-rc4 2024-09-17 08:05:30 -04:00
danieljsummers 95be82cc84 WIP on Fluid model/filters 2024-08-26 21:42:47 -04:00
danieljsummers d047035173 WIP: conversion to Fluid (#47) 2024-08-24 20:47:23 -04:00
danieljsummers cc3e41ddc5 Incorporate doc lib v4 ordering 2024-08-22 23:00:25 -04:00
danieljsummers d4c0e4e26c Tweak PostgreSQL calls 2024-08-19 22:45:14 -04:00
danieljsummers fbc4e891bd Integrate v4 document library for SQLite
- Eliminate warnings for PostgreSQL
2024-08-19 22:23:22 -04:00
danieljsummers cd450a05e5 Update to new doc library
Still need to remove deprecated calls
2024-08-19 20:30:33 -04:00
80 changed files with 8439 additions and 4438 deletions
+5 -5
View File
@@ -23,7 +23,7 @@ let version =
let appVersion = generator.Replace("\"Generator\": \"", "")
let appVersion = appVersion.Substring (0, appVersion.IndexOf "\"")
appVersion.Split ' ' |> Array.last
/// Zip a theme distributed with myWebLog
let zipTheme (name : string) (_ : TargetParameter) =
let path = $"src/{name}-theme"
@@ -33,9 +33,9 @@ let zipTheme (name : string) (_ : TargetParameter) =
|> Zip.zipSpec $"{releasePath}/{name}-theme.zip"
/// Frameworks supported by this build
let frameworks = [ "net6.0"; "net8.0" ]
let frameworks = [ "net8.0"; "net9.0" ]
/// Publish the project for the given runtime ID
/// Publish the project for the given runtime ID
let publishFor rid (_ : TargetParameter) =
frameworks
|> List.iter (fun fwk ->
@@ -65,7 +65,7 @@ let packageFor rid (_ : TargetParameter) =
Target.create "Clean" (fun _ ->
!! "src/**/bin"
++ "src/**/obj"
|> Shell.cleanDirs
|> Shell.cleanDirs
Shell.cleanDir releasePath
)
@@ -87,7 +87,7 @@ Target.create "RepackageLinux" (fun _ ->
frameworks
|> List.iter (fun fwk ->
let zipArchive = $"{releasePath}/myWebLog-{version}.{fwk}.linux-x64.zip"
let sh command args =
let sh command args =
CreateProcess.fromRawCommand command args
|> CreateProcess.redirectOutput
|> Proc.run
+6 -4
View File
@@ -1,9 +1,11 @@
<Project>
<PropertyGroup>
<TargetFrameworks>net6.0;net8.0</TargetFrameworks>
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
<DebugType>embedded</DebugType>
<AssemblyVersion>2.2.0.0</AssemblyVersion>
<FileVersion>2.2.0.0</FileVersion>
<Version>2.2.0</Version>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<AssemblyVersion>3.0.0.0</AssemblyVersion>
<FileVersion>3.0.0.0</FileVersion>
<Version>3.0.0</Version>
<VersionSuffix>beta1</VersionSuffix>
</PropertyGroup>
</Project>
+32
View File
@@ -0,0 +1,32 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="BitBadger.AspNetCore.CanonicalDomains" Version="1.1.0" />
<PackageVersion Include="BitBadger.Documents.Postgres" Version="4.3.0" />
<PackageVersion Include="BitBadger.Documents.Sqlite" Version="4.3.0" />
<PackageVersion Include="Expecto" Version="11.1.0" />
<PackageVersion Include="Fluid.Core" Version="2.31.0" />
<PackageVersion Include="FSharp.Core" Version="10.1.302" />
<PackageVersion Include="Giraffe" Version="8.2.0" />
<PackageVersion Include="Giraffe.Htmx" Version="4.0.0-beta5" />
<PackageVersion Include="Giraffe.ViewEngine.Htmx" Version="4.0.0-beta5" />
<PackageVersion Include="Markdig" Version="1.3.2" />
<PackageVersion Include="Markdown.ColorCode" Version="3.0.1" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
<PackageVersion Include="Microsoft.FSharpLu.Json" Version="0.11.7" />
<PackageVersion Include="NeoSmart.Caching.Sqlite.AspNetCore" Version="9.0.1" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<PackageVersion Include="NodaTime" Version="3.3.3" />
<PackageVersion Include="NodaTime.Serialization.JsonNet" Version="3.3.0" />
<PackageVersion Include="Npgsql.NodaTime" Version="10.0.3" />
<PackageVersion Include="RethinkDB.DistributedCache" Version="1.0.0-rc1" />
<PackageVersion Include="RethinkDb.Driver" Version="2.3.150" />
<PackageVersion Include="RethinkDb.Driver.FSharp" Version="0.9.0-beta-07" />
<PackageVersion Include="System.ServiceModel.Syndication" Version="10.0.10" />
<PackageVersion Include="ThrowawayDb.Postgres" Version="1.4.0" />
</ItemGroup>
</Project>
+100 -24
View File
@@ -1,14 +1,34 @@
/// Converters for discriminated union types
/// <summary>Converters for discriminated union types</summary>
module MyWebLog.Converters
open MyWebLog
open System
/// JSON.NET converters for discriminated union types
/// <summary>JSON.NET converters for discriminated union types</summary>
module Json =
open Newtonsoft.Json
/// <summary>Converter for the <see cref="AccessLevel" /> type</summary>
type AccessLevelConverter() =
inherit JsonConverter<AccessLevel>()
override _.WriteJson(writer: JsonWriter, value: AccessLevel, _: JsonSerializer) =
writer.WriteValue(string value)
override _.ReadJson(reader: JsonReader, _: Type, _: AccessLevel, _: bool, _: JsonSerializer) =
(string >> AccessLevel.Parse) reader.Value
/// <summary>Converter for the <see cref="AutoHtmxType" /> type</summary>
type AutoHtmxTypeConverter() =
inherit JsonConverter<AutoHtmxType>()
override _.WriteJson(writer: JsonWriter, value: AutoHtmxType, _: JsonSerializer) =
writer.WriteValue(string value)
override _.ReadJson(reader: JsonReader, _: Type, _: AutoHtmxType, _: bool, _: JsonSerializer) =
// Backups prior to v3 will still have true/false for this field
match reader.TokenType with
| JsonToken.Boolean -> if reader.Value :?> bool then AutoHtmx else NoAutoHtmx
| _ -> (string >> AutoHtmxType.Parse) reader.Value
/// <summary>Converter for the <see cref="CategoryId" /> type</summary>
type CategoryIdConverter() =
inherit JsonConverter<CategoryId>()
override _.WriteJson(writer: JsonWriter, value: CategoryId, _: JsonSerializer) =
@@ -16,6 +36,7 @@ module Json =
override _.ReadJson(reader: JsonReader, _: Type, _: CategoryId, _: bool, _: JsonSerializer) =
(string >> CategoryId) reader.Value
/// <summary>Converter for the <see cref="CommentId" /> type</summary>
type CommentIdConverter() =
inherit JsonConverter<CommentId>()
override _.WriteJson(writer: JsonWriter, value: CommentId, _: JsonSerializer) =
@@ -23,6 +44,7 @@ module Json =
override _.ReadJson(reader: JsonReader, _: Type, _: CommentId, _: bool, _: JsonSerializer) =
(string >> CommentId) reader.Value
/// <summary>Converter for the <see cref="CommentStatus" /> type</summary>
type CommentStatusConverter() =
inherit JsonConverter<CommentStatus>()
override _.WriteJson(writer: JsonWriter, value: CommentStatus, _: JsonSerializer) =
@@ -30,6 +52,7 @@ module Json =
override _.ReadJson(reader: JsonReader, _: Type, _: CommentStatus, _: bool, _: JsonSerializer) =
(string >> CommentStatus.Parse) reader.Value
/// <summary>Converter for the <see cref="CustomFeedId" /> type</summary>
type CustomFeedIdConverter() =
inherit JsonConverter<CustomFeedId>()
override _.WriteJson(writer: JsonWriter, value: CustomFeedId, _: JsonSerializer) =
@@ -37,34 +60,39 @@ module Json =
override _.ReadJson(reader: JsonReader, _: Type, _: CustomFeedId, _: bool, _: JsonSerializer) =
(string >> CustomFeedId) reader.Value
/// <summary>Converter for the <see cref="CustomFeedSource" /> type</summary>
type CustomFeedSourceConverter() =
inherit JsonConverter<CustomFeedSource>()
override _.WriteJson(writer: JsonWriter, value: CustomFeedSource, _: JsonSerializer) =
writer.WriteValue(string value)
override _.ReadJson(reader: JsonReader, _: Type, _: CustomFeedSource, _: bool, _: JsonSerializer) =
(string >> CustomFeedSource.Parse) reader.Value
/// <summary>Converter for the <see cref="ExplicitRating" /> type</summary>
type ExplicitRatingConverter() =
inherit JsonConverter<ExplicitRating>()
override _.WriteJson(writer: JsonWriter, value: ExplicitRating, _: JsonSerializer) =
writer.WriteValue(string value)
override _.ReadJson(reader: JsonReader, _: Type, _: ExplicitRating, _: bool, _: JsonSerializer) =
(string >> ExplicitRating.Parse) reader.Value
/// <summary>Converter for the <see cref="MarkupText" /> type</summary>
type MarkupTextConverter() =
inherit JsonConverter<MarkupText>()
override _.WriteJson(writer: JsonWriter, value: MarkupText, _: JsonSerializer) =
writer.WriteValue(string value)
override _.ReadJson(reader: JsonReader, _: Type, _: MarkupText, _: bool, _: JsonSerializer) =
(string >> MarkupText.Parse) reader.Value
type PermalinkConverter() =
inherit JsonConverter<Permalink>()
override _.WriteJson(writer: JsonWriter, value: Permalink, _: JsonSerializer) =
writer.WriteValue(string value)
override _.ReadJson(reader: JsonReader, _: Type, _: Permalink, _: bool, _: JsonSerializer) =
(string >> Permalink) reader.Value
/// <summary>Converter for the <see cref="OpenGraphType" /> type</summary>
type OpenGraphTypeConverter() =
inherit JsonConverter<OpenGraphType>()
override _.WriteJson(writer: JsonWriter, value: OpenGraphType, _: JsonSerializer) =
writer.WriteValue(string value)
override _.ReadJson(reader: JsonReader, _: Type, _: OpenGraphType, _: bool, _: JsonSerializer) =
(string >> OpenGraphType.Parse) reader.Value
/// <summary>Converter for the <see cref="PageId" /> type</summary>
type PageIdConverter() =
inherit JsonConverter<PageId>()
override _.WriteJson(writer: JsonWriter, value: PageId, _: JsonSerializer) =
@@ -72,6 +100,39 @@ module Json =
override _.ReadJson(reader: JsonReader, _: Type, _: PageId, _: bool, _: JsonSerializer) =
(string >> PageId) reader.Value
/// <summary>Converter for the <see cref="Permalink" /> type</summary>
type PermalinkConverter() =
inherit JsonConverter<Permalink>()
override _.WriteJson(writer: JsonWriter, value: Permalink, _: JsonSerializer) =
writer.WriteValue(string value)
override _.ReadJson(reader: JsonReader, _: Type, _: Permalink, _: bool, _: JsonSerializer) =
(string >> Permalink) reader.Value
/// <summary>Converter for the <see cref="PersonGroup" /> type</summary>
type PersonGroupConverter() =
inherit JsonConverter<PersonGroup>()
override _.WriteJson(writer: JsonWriter, value: PersonGroup, _: JsonSerializer) =
writer.WriteValue(string value)
override _.ReadJson(reader: JsonReader, _: Type, _: PersonGroup, _: bool, _: JsonSerializer) =
(string >> PersonGroup.Parse) reader.Value
/// <summary>Converter for the <see cref="PersonId" /> type</summary>
type PersonIdConverter() =
inherit JsonConverter<PersonId>()
override _.WriteJson(writer: JsonWriter, value: PersonId, _: JsonSerializer) =
writer.WriteValue(string value)
override _.ReadJson(reader: JsonReader, _: Type, _: PersonId, _: bool, _: JsonSerializer) =
(string >> PersonId) reader.Value
/// <summary>Converter for the <see cref="PersonRole" /> type</summary>
type PersonRoleConverter() =
inherit JsonConverter<PersonRole>()
override _.WriteJson(writer: JsonWriter, value: PersonRole, _: JsonSerializer) =
writer.WriteValue(string value)
override _.ReadJson(reader: JsonReader, _: Type, _: PersonRole, _: bool, _: JsonSerializer) =
(string >> PersonRole.Parse) reader.Value
/// <summary>Converter for the <see cref="PodcastMedium" /> type</summary>
type PodcastMediumConverter() =
inherit JsonConverter<PodcastMedium>()
override _.WriteJson(writer: JsonWriter, value: PodcastMedium, _: JsonSerializer) =
@@ -79,6 +140,7 @@ module Json =
override _.ReadJson(reader: JsonReader, _: Type, _: PodcastMedium, _: bool, _: JsonSerializer) =
(string >> PodcastMedium.Parse) reader.Value
/// <summary>Converter for the <see cref="PostId" /> type</summary>
type PostIdConverter() =
inherit JsonConverter<PostId>()
override _.WriteJson(writer: JsonWriter, value: PostId, _: JsonSerializer) =
@@ -86,6 +148,7 @@ module Json =
override _.ReadJson(reader: JsonReader, _: Type, _: PostId, _: bool, _: JsonSerializer) =
(string >> PostId) reader.Value
/// <summary>Converter for the <see cref="TagMapId" /> type</summary>
type TagMapIdConverter() =
inherit JsonConverter<TagMapId>()
override _.WriteJson(writer: JsonWriter, value: TagMapId, _: JsonSerializer) =
@@ -93,6 +156,7 @@ module Json =
override _.ReadJson(reader: JsonReader, _: Type, _: TagMapId, _: bool, _: JsonSerializer) =
(string >> TagMapId) reader.Value
/// <summary>Converter for the <see cref="ThemeAssetId" /> type</summary>
type ThemeAssetIdConverter() =
inherit JsonConverter<ThemeAssetId>()
override _.WriteJson(writer: JsonWriter, value: ThemeAssetId, _: JsonSerializer) =
@@ -100,20 +164,23 @@ module Json =
override _.ReadJson(reader: JsonReader, _: Type, _: ThemeAssetId, _: bool, _: JsonSerializer) =
(string >> ThemeAssetId.Parse) reader.Value
/// <summary>Converter for the <see cref="ThemeId" /> type</summary>
type ThemeIdConverter() =
inherit JsonConverter<ThemeId>()
override _.WriteJson(writer: JsonWriter, value: ThemeId, _: JsonSerializer) =
writer.WriteValue(string value)
override _.ReadJson(reader: JsonReader, _: Type, _: ThemeId, _: bool, _: JsonSerializer) =
(string >> ThemeId) reader.Value
/// <summary>Converter for the <see cref="UploadId" /> type</summary>
type UploadIdConverter() =
inherit JsonConverter<UploadId>()
override _.WriteJson(writer: JsonWriter, value: UploadId, _: JsonSerializer) =
writer.WriteValue(string value)
override _.ReadJson(reader: JsonReader, _: Type, _: UploadId, _: bool, _: JsonSerializer) =
(string >> UploadId) reader.Value
/// <summary>Converter for the <see cref="WebLogId" /> type</summary>
type WebLogIdConverter() =
inherit JsonConverter<WebLogId>()
override _.WriteJson(writer: JsonWriter, value: WebLogId, _: JsonSerializer) =
@@ -121,6 +188,7 @@ module Json =
override _.ReadJson(reader: JsonReader, _: Type, _: WebLogId, _: bool, _: JsonSerializer) =
(string >> WebLogId) reader.Value
/// <summary>Converter for the <see cref="WebLogUserId" /> type</summary>
type WebLogUserIdConverter() =
inherit JsonConverter<WebLogUserId> ()
override _.WriteJson(writer: JsonWriter, value: WebLogUserId, _: JsonSerializer) =
@@ -131,19 +199,25 @@ module Json =
open Microsoft.FSharpLu.Json
open NodaTime
open NodaTime.Serialization.JsonNet
/// Configure a serializer to use these converters
let configure (ser : JsonSerializer) =
/// <summary>Configure a serializer to use these converters (and other settings)</summary>
let configure (ser: JsonSerializer) =
// Our converters
[ CategoryIdConverter() :> JsonConverter
[ AccessLevelConverter() :> JsonConverter
AutoHtmxTypeConverter()
CategoryIdConverter()
CommentIdConverter()
CommentStatusConverter()
CustomFeedIdConverter()
CustomFeedSourceConverter()
ExplicitRatingConverter()
MarkupTextConverter()
PermalinkConverter()
OpenGraphTypeConverter()
PageIdConverter()
PermalinkConverter()
PersonGroupConverter()
PersonIdConverter()
PersonRoleConverter()
PodcastMediumConverter()
PostIdConverter()
TagMapIdConverter()
@@ -160,12 +234,14 @@ module Json =
ser.NullValueHandling <- NullValueHandling.Ignore
ser.MissingMemberHandling <- MissingMemberHandling.Ignore
ser
/// Serializer settings extracted from a JsonSerializer (a property sure would be nice...)
let mutable private serializerSettings : JsonSerializerSettings option = None
/// Extract settings from the serializer to be used in JsonConvert calls
let settings (ser : JsonSerializer) =
/// <summary>Extract settings from the serializer to be used in <c>JsonConvert</c> calls</summary>
/// <param name="ser">The serializer from which settings will be extracted if required</param>
/// <returns>The serializer settings to use for <c>JsonConvert</c> calls</returns>
let settings (ser: JsonSerializer) =
if Option.isNone serializerSettings then
serializerSettings <- JsonSerializerSettings (
ConstructorHandling = ser.ConstructorHandling,
+28
View File
@@ -97,6 +97,31 @@ type IPageData =
abstract member UpdatePriorPermalinks : PageId -> WebLogId -> Permalink list -> Task<bool>
/// Data functions to support manipulating persons
type IPersonData =
/// Add a person
abstract member Add : Person -> Task<unit>
/// Delete a person
abstract member Delete : PersonId -> WebLogId -> Task<bool>
/// Find all persons by their given assignments
abstract member FindByAssignment : PersonAssignment list -> Task<Person list>
/// Find a person by their ID
abstract member FindById : PersonId -> WebLogId -> Task<Person option>
/// Find persons for the given web log
abstract member FindByWebLog : WebLogId -> Task<Person list>
/// Restore persons from a backup
abstract member Restore : Person list -> Task<unit>
/// Update a person's information
abstract member Update : Person -> Task<unit>
/// Data functions to support manipulating posts
type IPostData =
@@ -310,6 +335,9 @@ type IData =
/// Page data functions
abstract member Page : IPageData
/// Person data functions
abstract member Person : IPersonData
/// Post data functions
abstract member Post : IPostData
+13 -11
View File
@@ -5,17 +5,17 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="BitBadger.Documents.Postgres" Version="3.1.0" />
<PackageReference Include="BitBadger.Documents.Sqlite" Version="3.1.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.6" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.7" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.1.0" />
<PackageReference Include="Npgsql.NodaTime" Version="8.0.3" />
<PackageReference Include="RethinkDb.Driver" Version="2.3.150" />
<PackageReference Include="RethinkDb.Driver.FSharp" Version="0.9.0-beta-07" />
<PackageReference Update="FSharp.Core" Version="8.0.300" />
<PackageReference Include="BitBadger.Documents.Postgres" />
<PackageReference Include="BitBadger.Documents.Sqlite" />
<PackageReference Include="FSharp.Core" />
<PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.FSharpLu.Json" />
<PackageReference Include="NodaTime.Serialization.JsonNet" />
<PackageReference Include="Npgsql.NodaTime" />
<PackageReference Include="RethinkDb.Driver" />
<PackageReference Include="RethinkDb.Driver.FSharp" />
</ItemGroup>
<ItemGroup>
@@ -26,6 +26,7 @@
<Compile Include="SQLite\SQLiteHelpers.fs" />
<Compile Include="SQLite\SQLiteCategoryData.fs" />
<Compile Include="SQLite\SQLitePageData.fs" />
<Compile Include="SQLite\SQLitePersonData.fs" />
<Compile Include="SQLite\SQLitePostData.fs" />
<Compile Include="SQLite\SQLiteTagMapData.fs" />
<Compile Include="SQLite\SQLiteThemeData.fs" />
@@ -37,6 +38,7 @@
<Compile Include="Postgres\PostgresCache.fs" />
<Compile Include="Postgres\PostgresCategoryData.fs" />
<Compile Include="Postgres\PostgresPageData.fs" />
<Compile Include="Postgres\PostgresPersonData.fs" />
<Compile Include="Postgres\PostgresPostData.fs" />
<Compile Include="Postgres\PostgresTagMapData.fs" />
<Compile Include="Postgres\PostgresThemeData.fs" />
@@ -19,38 +19,41 @@ type PostgresCategoryData(log: ILogger) =
let countTopLevel webLogId =
log.LogTrace "Category.countTopLevel"
Custom.scalar
$"""{Query.Count.byContains Table.Category}
AND {Query.whereByField (Field.NEX (nameof Category.Empty.ParentId)) ""}"""
$"""{Query.byContains (Query.count Table.Category)}
AND {Query.whereByFields Any [ Field.NotExists (nameof Category.Empty.ParentId) ]}"""
[ webLogContains webLogId ]
toCount
/// Find all categories for the given web log
let findByWebLog webLogId =
log.LogTrace "Category.findByWebLog"
Find.byContains<Category> Table.Category (webLogDoc webLogId)
/// Retrieve all categories for the given web log in a DotLiquid-friendly format
let findAllForView webLogId = backgroundTask {
log.LogTrace "Category.findAllForView"
let! cats =
Custom.list
$"{selectWithCriteria Table.Category} ORDER BY LOWER(data ->> '{nameof Category.Empty.Name}')"
[ webLogContains webLogId ]
fromData<Category>
let ordered = Utils.orderByHierarchy cats None None []
let counts =
let! cats = findByWebLog webLogId
let ordered = Utils.orderByHierarchy (cats |> List.sortBy _.Name.ToLowerInvariant()) None None []
let counts =
ordered
|> Seq.map (fun it ->
// Parent category post counts include posts in subcategories
let catIdSql, catIdParams =
let catIdField =
ordered
|> Seq.filter (fun cat -> cat.ParentNames |> Array.contains it.Name)
|> Seq.map _.Id
|> Seq.append (Seq.singleton it.Id)
|> List.ofSeq
|> arrayContains (nameof Post.Empty.CategoryIds) id
|> Field.InArray (nameof Post.Empty.CategoryIds) Table.Post
let query =
(Query.statementWhere
(Query.count Table.Post)
$"""{Query.whereDataContains "@criteria"} AND {Query.whereByFields All [ catIdField ]}""")
.Replace("(*)", $"(DISTINCT data->>'{nameof Post.Empty.Id}')")
let postCount =
Custom.scalar
$"""SELECT COUNT(DISTINCT data ->> '{nameof Post.Empty.Id}') AS it
FROM {Table.Post}
WHERE {Query.whereDataContains "@criteria"}
AND {catIdSql}"""
[ jsonParam "@criteria" {| webLogDoc webLogId with Status = Published |}; catIdParams ]
query
(addFieldParams
[ catIdField ] [ jsonParam "@criteria" {| webLogDoc webLogId with Status = Published |} ])
toCount
|> Async.AwaitTask
|> Async.RunSynchronously
@@ -71,11 +74,6 @@ type PostgresCategoryData(log: ILogger) =
log.LogTrace "Category.findById"
Document.findByIdAndWebLog<CategoryId, Category> Table.Category catId webLogId
/// Find all categories for the given web log
let findByWebLog webLogId =
log.LogTrace "Category.findByWebLog"
Document.findByWebLog<Category> Table.Category webLogId
/// Delete a category
let delete catId webLogId = backgroundTask {
log.LogTrace "Category.delete"
@@ -87,14 +85,14 @@ type PostgresCategoryData(log: ILogger) =
if hasChildren then
let childQuery, childParams =
if cat.ParentId.IsSome then
Query.Patch.byId Table.Category,
Query.byId (Query.patch Table.Category) "",
children
|> List.map (fun child -> [ idParam child.Id; jsonParam "@data" {| ParentId = cat.ParentId |} ])
else
Query.RemoveFields.byId Table.Category,
Query.byId (Query.removeFields Table.Category) "",
children
|> List.map (fun child ->
[ idParam child.Id; fieldNameParam [ nameof Category.Empty.ParentId ] ])
[ idParam child.Id; fieldNameParams [ nameof Category.Empty.ParentId ] ])
let! _ =
Configuration.dataSource ()
|> Sql.fromDataSource
@@ -103,7 +101,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"
$"SELECT data FROM {Table.Post} WHERE data->'{nameof Post.Empty.CategoryIds}' @> @id"
[ jsonParam "@id" [| string catId |] ]
fromData<Post>
if not (List.isEmpty posts) then
@@ -111,7 +109,7 @@ type PostgresCategoryData(log: ILogger) =
Configuration.dataSource ()
|> Sql.fromDataSource
|> Sql.executeTransactionAsync
[ Query.Patch.byId Table.Post,
[ Query.byId (Query.patch Table.Post) "",
posts
|> List.map (fun post ->
[ idParam post.Id
+7 -28
View File
@@ -22,6 +22,10 @@ module Table =
[<Literal>]
let PageRevision = "page_revision"
/// Persons
[<Literal>]
let Person = "person"
/// Posts
[<Literal>]
let Post = "post"
@@ -83,28 +87,7 @@ let webLogContains webLogId =
/// A SQL string to select data from a table with the given JSON document contains criteria
let selectWithCriteria tableName =
$"""{Query.selectFromTable tableName} WHERE {Query.whereDataContains "@criteria"}"""
/// Create the SQL and parameters for an IN clause
let inClause<'T> colNameAndPrefix paramName (items: 'T list) =
if List.isEmpty items then "", []
else
let mutable idx = 0
items
|> List.skip 1
|> List.fold (fun (itemS, itemP) it ->
idx <- idx + 1
$"{itemS}, @%s{paramName}{idx}", ($"@%s{paramName}{idx}", Sql.string (string it)) :: itemP)
(Seq.ofList items
|> Seq.map (fun it ->
$"%s{colNameAndPrefix} IN (@%s{paramName}0", [ $"@%s{paramName}0", Sql.string (string it) ])
|> Seq.head)
|> function sql, ps -> $"{sql})", ps
/// Create the SQL and parameters for match-any array query
let arrayContains<'T> name (valueFunc: 'T -> string) (items: 'T list) =
$"data['{name}'] ?| @{name}Values",
($"@{name}Values", Sql.stringArray (items |> List.map valueFunc |> Array.ofList))
Query.byContains (Query.find tableName)
/// Get the first result of the given query
let tryHead<'T> (query: Task<'T list>) = backgroundTask {
@@ -162,13 +145,9 @@ module Document =
/// Find a document by its ID for the given web log
let findByIdAndWebLog<'TKey, 'TDoc> table (key: 'TKey) webLogId =
Custom.single
$"""{Query.selectFromTable table} WHERE {Query.whereById "@id"} AND {Query.whereDataContains "@criteria"}"""
$"""{Query.find table} WHERE {Query.whereById "@id"} AND {Query.whereDataContains "@criteria"}"""
[ "@id", Sql.string (string key); webLogContains webLogId ]
fromData<'TDoc>
/// Find documents for the given web log
let findByWebLog<'TDoc> table webLogId : Task<'TDoc list> =
Find.byContains table (webLogDoc webLogId)
/// Functions to support revisions
@@ -186,7 +165,7 @@ module Revisions =
Custom.list
$"""SELECT pr.*
FROM %s{revTable} pr
INNER JOIN %s{entityTable} p ON p.data ->> '{nameof Post.Empty.Id}' = pr.{entityTable}_id
INNER JOIN %s{entityTable} p ON p.data->>'{nameof Post.Empty.Id}' = pr.{entityTable}_id
WHERE p.{Query.whereDataContains "@criteria"}
ORDER BY as_of DESC"""
[ webLogContains webLogId ]
+17 -19
View File
@@ -33,6 +33,10 @@ type PostgresPageData(log: ILogger) =
log.LogTrace "Page.pageExists"
Document.existsByWebLog Table.Page pageId webLogId
/// The query to get all pages ordered by title
let sortedPages =
selectWithCriteria Table.Page + Query.orderBy [ Field.Named $"i:{nameof Page.Empty.Title}" ] PostgreSQL
// IMPLEMENTATION FUNCTIONS
/// Add a page
@@ -47,7 +51,7 @@ type PostgresPageData(log: ILogger) =
let all webLogId =
log.LogTrace "Page.all"
Custom.list
$"{selectWithCriteria Table.Page} ORDER BY LOWER(data ->> '{nameof Page.Empty.Title}')"
sortedPages
[ webLogContains webLogId ]
(fun row -> { fromData<Page> row with Text = ""; Metadata = []; PriorPermalinks = [] })
@@ -86,8 +90,8 @@ type PostgresPageData(log: ILogger) =
match! pageExists pageId webLogId with
| true ->
do! Custom.nonQuery
$"""DELETE FROM {Table.PageRevision} WHERE page_id = @id;
DELETE FROM {Table.Page} WHERE {Query.whereById "@id"}"""
$"""{Query.delete Table.PageRevision} WHERE page_id = @id;
{Query.delete Table.Page} WHERE {Query.whereById "@id"}"""
[ idParam pageId ]
return true
| false -> return false
@@ -107,21 +111,19 @@ 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
return!
Custom.single
$"""SELECT data ->> '{nameof Page.Empty.Permalink}' AS permalink
FROM page
WHERE {Query.whereDataContains "@criteria"}
AND {linkSql}"""
[ webLogContains webLogId; linkParam ]
Map.toPermalink
let linkField = Field.InArray (nameof Page.Empty.PriorPermalinks) Table.Page (List.map string permalinks)
let query =
(Query.statementWhere
(Query.find Table.Page)
$"""{Query.whereDataContains "@criteria"} AND {Query.whereByFields All [ linkField ]}""")
.Replace("SELECT data", $"SELECT data->>'{nameof Page.Empty.Permalink}' AS permalink")
return! Custom.single query (addFieldParams [ linkField ] [ webLogContains webLogId ]) Map.toPermalink
}
/// Get all complete pages for the given web log
let findFullByWebLog webLogId = backgroundTask {
log.LogTrace "Page.findFullByWebLog"
let! pages = Document.findByWebLog<Page> Table.Page webLogId
let! pages = Find.byContains<Page> Table.Page (webLogDoc webLogId)
let! revisions = Revisions.findByWebLog Table.PageRevision Table.Page PageId webLogId
return
pages
@@ -133,17 +135,13 @@ type PostgresPageData(log: ILogger) =
let findListed webLogId =
log.LogTrace "Page.findListed"
Custom.list
$"{selectWithCriteria Table.Page} ORDER BY LOWER(data ->> '{nameof Page.Empty.Title}')"
[ jsonParam "@criteria" {| webLogDoc webLogId with IsInPageList = true |} ]
pageWithoutText
sortedPages [ jsonParam "@criteria" {| webLogDoc webLogId with IsInPageList = true |} ] pageWithoutText
/// Get a page of pages for the given web log (without revisions)
let findPageOfPages webLogId pageNbr =
log.LogTrace "Page.findPageOfPages"
Custom.list
$"{selectWithCriteria Table.Page}
ORDER BY LOWER(data->>'{nameof Page.Empty.Title}')
LIMIT @pageSize OFFSET @toSkip"
$"{sortedPages} LIMIT @pageSize OFFSET @toSkip"
[ webLogContains webLogId; "@pageSize", Sql.int 26; "@toSkip", Sql.int ((pageNbr - 1) * 25) ]
(fun row -> { fromData<Page> row with Metadata = []; PriorPermalinks = [] })
@@ -0,0 +1,77 @@
namespace MyWebLog.Data.Postgres
open BitBadger.Documents
open BitBadger.Documents.Postgres
open Microsoft.Extensions.Logging
open MyWebLog
open MyWebLog.Data
open Npgsql.FSharp
/// PostgreSQL myWebLog person data implementation
type PostgresPersonData(log: ILogger) =
/// Add a person
let add (person : Person) =
log.LogTrace "Person.add"
insert Table.Person person
/// Find persons for the given assignments
let findByAssignment (assignments: PersonAssignment list) =
log.LogTrace "Person.findByAssignment"
Find.byFieldsOrdered<Person>
Table.Person
All
[ Field.In (nameof Person.Empty.Id) (assignments |> List.map (fun it -> string it.PersonId)) ]
[ Field.Named (nameof Person.Empty.Name) ]
/// Find a person by their ID for the given web log
let findById (personId: PersonId) (webLogId: WebLogId) =
log.LogTrace "Person.findById"
Find.firstByContains<Person> Table.Person {| Id = personId; WebLogId = webLogId |}
/// Delete a person by their ID for the given web log
let delete personId webLogId = backgroundTask {
log.LogTrace "Person.delete"
match! findById personId webLogId with
| Some _ ->
// TODO: also remove this person from assignments in episodes and podcasts
do! Custom.nonQuery
$"""{Query.delete Table.Person} WHERE {Query.whereById "@id"}"""
[ idParam personId ]
return true
| None -> return false
}
/// Find all persons for the given web log
let findByWebLog webLogId =
log.LogTrace "Person.findByWebLog"
Find.byContainsOrdered<Person> Table.Person (webLogDoc webLogId) [ Field.Named (nameof Person.Empty.Name) ]
/// Restore persons from a backup
let restore persons = backgroundTask {
log.LogTrace "Person.restore"
let! _ =
Configuration.dataSource ()
|> Sql.fromDataSource
|> Sql.executeTransactionAsync
[ Query.insert Table.Person, persons |> List.map (fun person -> [ jsonParam "@data" person ]) ]
()
}
/// Update a person
let update (person: Person) = backgroundTask {
log.LogTrace "Person.update"
match! findById person.Id person.WebLogId with
| Some _ -> do! Update.byId Table.Person person.Id person
| None -> ()
}
interface IPersonData with
member _.Add person = add person
member _.Delete personId webLogId = delete personId webLogId
member _.FindByAssignment assignments = findByAssignment assignments
member _.FindById personId webLogId = findById personId webLogId
member _.FindByWebLog webLogId = findByWebLog webLogId
member _.Restore persons = restore persons
member _.Update person = update person
+35 -34
View File
@@ -84,9 +84,9 @@ type PostgresPostData(log: ILogger) =
match! postExists postId webLogId with
| true ->
do! Custom.nonQuery
$"""DELETE FROM {Table.PostComment} WHERE {Query.whereDataContains "@criteria"};
DELETE FROM {Table.PostRevision} WHERE post_id = @id;
DELETE FROM {Table.Post} WHERE {Query.whereById "@id"}"""
$"""{Query.delete Table.PostComment} WHERE {Query.whereDataContains "@criteria"};
{Query.delete Table.PostRevision} WHERE post_id = @id;
{Query.delete Table.Post} WHERE {Query.whereById "@id"}"""
[ idParam postId; jsonParam "@criteria" {| PostId = postId |} ]
return true
| false -> return false
@@ -97,21 +97,19 @@ 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
return!
Custom.single
$"""SELECT data ->> '{nameof Post.Empty.Permalink}' AS permalink
FROM {Table.Post}
WHERE {Query.whereDataContains "@criteria"}
AND {linkSql}"""
[ webLogContains webLogId; linkParam ]
Map.toPermalink
let linkField = Field.InArray (nameof Post.Empty.PriorPermalinks) Table.Post (List.map string permalinks)
let query =
(Query.statementWhere
(Query.find Table.Post)
$"""{Query.whereDataContains "@criteria"} AND {Query.whereByFields All [ linkField ]}""")
.Replace("SELECT data", $"SELECT data->>'{nameof Post.Empty.Permalink}' AS permalink")
return! Custom.single query (addFieldParams [ linkField ] [ webLogContains webLogId ]) Map.toPermalink
}
/// Get all complete posts for the given web log
let findFullByWebLog webLogId = backgroundTask {
log.LogTrace "Post.findFullByWebLog"
let! posts = Document.findByWebLog<Post> Table.Post webLogId
let! posts = Find.byContains<Post> Table.Post (webLogDoc webLogId)
let! revisions = Revisions.findByWebLog Table.PostRevision Table.Post PostId webLogId
return
posts
@@ -122,23 +120,26 @@ 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 catIdField = Field.InArray (nameof Post.Empty.CategoryIds) Table.Post (List.map string categoryIds)
Custom.list
$"{selectWithCriteria Table.Post}
AND {catSql}
ORDER BY data ->> '{nameof Post.Empty.PublishedOn}' DESC
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
[ jsonParam "@criteria" {| webLogDoc webLogId with Status = Published |}; catParam ]
$"""{selectWithCriteria Table.Post}
AND {Query.whereByFields All [ catIdField ]}
{Query.orderBy [ Field.Named $"{nameof Post.Empty.PublishedOn} DESC" ] PostgreSQL}
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"""
(addFieldParams [ catIdField] [ jsonParam "@criteria" {| webLogDoc webLogId with Status = Published |} ])
postWithoutLinks
/// Get a page of posts for the given web log (excludes text and revisions)
let findPageOfPosts webLogId pageNbr postsPerPage =
log.LogTrace "Post.findPageOfPosts"
let order =
Query.orderBy
[ Field.Named $"{nameof Post.Empty.PublishedOn} DESC NULLS FIRST"
Field.Named (nameof Post.Empty.UpdatedOn) ]
PostgreSQL
Custom.list
$"{selectWithCriteria Table.Post}
ORDER BY data ->> '{nameof Post.Empty.PublishedOn}' DESC NULLS FIRST,
data ->> '{nameof Post.Empty.UpdatedOn}'
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
$"{selectWithCriteria Table.Post}{order}
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
[ webLogContains webLogId ]
postWithoutText
@@ -146,9 +147,9 @@ type PostgresPostData(log: ILogger) =
let findPageOfPublishedPosts webLogId pageNbr postsPerPage =
log.LogTrace "Post.findPageOfPublishedPosts"
Custom.list
$"{selectWithCriteria Table.Post}
ORDER BY data ->> '{nameof Post.Empty.PublishedOn}' DESC
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
$"""{selectWithCriteria Table.Post}
{Query.orderBy [ Field.Named $"{nameof Post.Empty.PublishedOn} DESC" ] PostgreSQL}
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"""
[ jsonParam "@criteria" {| webLogDoc webLogId with Status = Published |} ]
postWithoutLinks
@@ -156,10 +157,10 @@ type PostgresPostData(log: ILogger) =
let findPageOfTaggedPosts webLogId (tag: string) pageNbr postsPerPage =
log.LogTrace "Post.findPageOfTaggedPosts"
Custom.list
$"{selectWithCriteria Table.Post}
AND data['{nameof Post.Empty.Tags}'] @> @tag
ORDER BY data ->> '{nameof Post.Empty.PublishedOn}' DESC
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
$"""{selectWithCriteria Table.Post}
AND data['{nameof Post.Empty.Tags}'] @> @tag
{Query.orderBy [ Field.Named $"{nameof Post.Empty.PublishedOn} DESC" ] PostgreSQL}
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"""
[ jsonParam "@criteria" {| webLogDoc webLogId with Status = Published |}; jsonParam "@tag" [| tag |] ]
postWithoutLinks
@@ -170,10 +171,10 @@ type PostgresPostData(log: ILogger) =
[ jsonParam "@criteria" {| webLogDoc webLogId with Status = Published |}
"@publishedOn", Sql.timestamptz (publishedOn.ToDateTimeOffset()) ]
let query op direction =
$"{selectWithCriteria Table.Post}
AND (data ->> '{nameof Post.Empty.PublishedOn}')::timestamp with time zone %s{op} @publishedOn
ORDER BY data ->> '{nameof Post.Empty.PublishedOn}' %s{direction}
LIMIT 1"
$"""{selectWithCriteria Table.Post}
AND (data->>'{nameof Post.Empty.PublishedOn}')::timestamp with time zone %s{op} @publishedOn
{Query.orderBy [ Field.Named $"{nameof Post.Empty.PublishedOn} %s{direction}" ] PostgreSQL}
LIMIT 1"""
let! older = Custom.list (query "<" "DESC") (queryParams ()) postWithoutLinks
let! newer = Custom.list (query ">" "") (queryParams ()) postWithoutLinks
return List.tryHead older, List.tryHead newer
@@ -33,18 +33,15 @@ type PostgresTagMapData(log: ILogger) =
/// Get all tag mappings for the given web log
let findByWebLog webLogId =
log.LogTrace "TagMap.findByWebLog"
Custom.list
$"{selectWithCriteria Table.TagMap} ORDER BY data ->> 'tag'"
[ webLogContains webLogId ]
fromData<TagMap>
Find.byContainsOrdered<TagMap> Table.TagMap (webLogDoc webLogId) [ Field.Named (nameof TagMap.Empty.Tag) ]
/// Find any tag mappings in a list of tags for the given web log
let findMappingForTags tags webLogId =
let findMappingForTags (tags: string list) webLogId =
log.LogTrace "TagMap.findMappingForTags"
let tagSql, tagParam = arrayContains (nameof TagMap.Empty.Tag) id tags
let tagField = Field.InArray (nameof TagMap.Empty.Tag) Table.TagMap tags
Custom.list
$"{selectWithCriteria Table.TagMap} AND {tagSql}"
[ webLogContains webLogId; tagParam ]
$"{selectWithCriteria Table.TagMap} AND {Query.whereByFields All [ tagField ]}"
(addFieldParams [ tagField ] [ webLogContains webLogId ])
fromData<TagMap>
/// Save a tag mapping
@@ -17,11 +17,11 @@ type PostgresThemeData(log: ILogger) =
/// Retrieve all themes (except 'admin'; excludes template text)
let all () =
log.LogTrace "Theme.all"
let fields = [ Field.NotEqual (nameof Theme.Empty.Id) "admin" ]
Custom.list
$"{Query.selectFromTable Table.Theme}
WHERE data ->> '{nameof Theme.Empty.Id}' <> 'admin'
ORDER BY data ->> '{nameof Theme.Empty.Id}'"
[]
(Query.byFields (Query.find Table.Theme) Any fields
+ Query.orderBy [ Field.Named (nameof Theme.Empty.Id) ] PostgreSQL)
(addFieldParams fields [])
withoutTemplateText
/// Does a given theme exist?
@@ -37,7 +37,7 @@ type PostgresThemeData(log: ILogger) =
/// Find a theme by its ID (excludes the text of templates)
let findByIdWithoutText (themeId: ThemeId) =
log.LogTrace "Theme.findByIdWithoutText"
Custom.single (Query.Find.byId Table.Theme) [ idParam themeId ] withoutTemplateText
Custom.single (Query.byId (Query.find Table.Theme) (string themeId)) [ idParam themeId ] withoutTemplateText
/// Delete a theme by its ID
let delete themeId = backgroundTask {
@@ -45,8 +45,8 @@ type PostgresThemeData(log: ILogger) =
match! exists themeId with
| true ->
do! Custom.nonQuery
$"""DELETE FROM {Table.ThemeAsset} WHERE theme_id = @id;
DELETE FROM {Table.Theme} WHERE {Query.whereById "@id"}"""
$"""{Query.delete Table.ThemeAsset} WHERE theme_id = @id;
{Query.delete Table.Theme} WHERE {Query.whereById "@id"}"""
[ idParam themeId ]
return true
| false -> return false
@@ -77,7 +77,7 @@ type PostgresThemeAssetData(log: ILogger) =
/// Delete all assets for the given theme
let deleteByTheme (themeId: ThemeId) =
log.LogTrace "ThemeAsset.deleteByTheme"
Custom.nonQuery $"DELETE FROM {Table.ThemeAsset} WHERE theme_id = @id" [ idParam themeId ]
Custom.nonQuery $"{Query.delete Table.ThemeAsset} WHERE theme_id = @id" [ idParam themeId ]
/// Find a theme asset by its ID
let findById assetId =
@@ -23,22 +23,22 @@ type PostgresWebLogData(log: ILogger) =
let delete webLogId =
log.LogTrace "WebLog.delete"
Custom.nonQuery
$"""DELETE FROM {Table.PostComment}
WHERE data ->> '{nameof Comment.Empty.PostId}'
IN (SELECT data ->> '{nameof Post.Empty.Id}'
FROM {Table.Post}
WHERE {Query.whereDataContains "@criteria"});
DELETE FROM {Table.PostRevision}
WHERE post_id IN (SELECT data ->> 'Id' FROM {Table.Post} WHERE {Query.whereDataContains "@criteria"});
DELETE FROM {Table.PageRevision}
WHERE page_id IN (SELECT data ->> 'Id' FROM {Table.Page} WHERE {Query.whereDataContains "@criteria"});
{Query.Delete.byContains Table.Post};
{Query.Delete.byContains Table.Page};
{Query.Delete.byContains Table.Category};
{Query.Delete.byContains Table.TagMap};
{Query.Delete.byContains Table.WebLogUser};
DELETE FROM {Table.Upload} WHERE web_log_id = @webLogId;
DELETE FROM {Table.WebLog} WHERE {Query.whereById "@webLogId"}"""
$"""{Query.delete Table.PostComment}
WHERE data->>'{nameof Comment.Empty.PostId}'
IN (SELECT data->>'{nameof Post.Empty.Id}'
FROM {Table.Post}
WHERE {Query.whereDataContains "@criteria"});
{Query.delete Table.PostRevision}
WHERE post_id IN (SELECT data->>'Id' FROM {Table.Post} WHERE {Query.whereDataContains "@criteria"});
{Query.delete Table.PageRevision}
WHERE page_id IN (SELECT data->>'Id' FROM {Table.Page} WHERE {Query.whereDataContains "@criteria"});
{Query.byContains (Query.delete Table.Post)};
{Query.byContains (Query.delete Table.Page)};
{Query.byContains (Query.delete Table.Category)};
{Query.byContains (Query.delete Table.TagMap)};
{Query.byContains (Query.delete Table.WebLogUser)};
{Query.delete Table.Upload} WHERE web_log_id = @webLogId;
{Query.delete Table.WebLog} WHERE data->>'Id' = @webLogId"""
[ webLogIdParam webLogId; webLogContains webLogId ]
/// Find a web log by its host (URL base)
@@ -49,19 +49,17 @@ type PostgresWebLogUserData(log: ILogger) =
/// Get all users for the given web log
let findByWebLog webLogId =
log.LogTrace "WebLogUser.findByWebLog"
Custom.list
$"{selectWithCriteria Table.WebLogUser} ORDER BY LOWER(data ->> '{nameof WebLogUser.Empty.PreferredName}')"
[ webLogContains webLogId ]
fromData<WebLogUser>
Find.byContainsOrdered<WebLogUser>
Table.WebLogUser (webLogDoc webLogId) [ Field.Named $"i:{nameof WebLogUser.Empty.PreferredName}" ]
/// Find the names of users by their IDs for the given web log
let findNames webLogId (userIds: WebLogUserId list) = backgroundTask {
log.LogTrace "WebLogUser.findNames"
let idSql, idParams = inClause $"AND data ->> '{nameof WebLogUser.Empty.Id}'" "id" userIds
let idField = Field.In (nameof WebLogUser.Empty.Id) (List.map string userIds)
let! users =
Custom.list
$"{selectWithCriteria Table.WebLogUser} {idSql}"
(webLogContains webLogId :: idParams)
$"{selectWithCriteria Table.WebLogUser} AND {Query.whereByFields All [ idField ]}"
(addFieldParams [ idField ] [ webLogContains webLogId ])
fromData<WebLogUser>
return users |> List.map (fun u -> { Name = string u.Id; Value = u.DisplayName })
}
+73 -48
View File
@@ -10,22 +10,22 @@ open Npgsql.FSharp
/// Data implementation for PostgreSQL
type PostgresData(log: ILogger<PostgresData>, ser: JsonSerializer) =
/// Create any needed tables
let ensureTables () = backgroundTask {
// Set up the PostgreSQL document store
Configuration.useSerializer (Utils.createDocumentSerializer ser)
let! tables =
Custom.list
"SELECT tablename FROM pg_tables WHERE schemaname = 'public'" [] (fun row -> row.string "tablename")
let needsTable table = not (List.contains table tables)
let sql = seq {
// Theme tables
if needsTable Table.Theme then
Query.Definition.ensureTable Table.Theme
Query.Definition.ensureKey Table.Theme
Query.Definition.ensureKey Table.Theme PostgreSQL
if needsTable Table.ThemeAsset then
$"CREATE TABLE {Table.ThemeAsset} (
theme_id TEXT NOT NULL,
@@ -33,50 +33,57 @@ type PostgresData(log: ILogger<PostgresData>, ser: JsonSerializer) =
updated_on TIMESTAMPTZ NOT NULL,
data BYTEA NOT NULL,
PRIMARY KEY (theme_id, path))"
// Web log table
if needsTable Table.WebLog then
Query.Definition.ensureTable Table.WebLog
Query.Definition.ensureKey Table.WebLog
Query.Definition.ensureKey Table.WebLog PostgreSQL
Query.Definition.ensureDocumentIndex Table.WebLog Optimized
// Category table
if needsTable Table.Category then
Query.Definition.ensureTable Table.Category
Query.Definition.ensureKey Table.Category
Query.Definition.ensureKey Table.Category PostgreSQL
Query.Definition.ensureDocumentIndex Table.Category Optimized
// Web log user table
if needsTable Table.WebLogUser then
Query.Definition.ensureTable Table.WebLogUser
Query.Definition.ensureKey Table.WebLogUser
Query.Definition.ensureKey Table.WebLogUser PostgreSQL
Query.Definition.ensureDocumentIndex Table.WebLogUser Optimized
// Page tables
if needsTable Table.Page then
Query.Definition.ensureTable Table.Page
Query.Definition.ensureKey Table.Page
Query.Definition.ensureIndexOn Table.Page "author" [ nameof Page.Empty.AuthorId ]
Query.Definition.ensureKey Table.Page PostgreSQL
Query.Definition.ensureIndexOn Table.Page "author" [ nameof Page.Empty.AuthorId ] PostgreSQL
Query.Definition.ensureIndexOn
Table.Page "permalink" [ nameof Page.Empty.WebLogId; nameof Page.Empty.Permalink ]
Table.Page "permalink" [ nameof Page.Empty.WebLogId; nameof Page.Empty.Permalink ] PostgreSQL
if needsTable Table.PageRevision then
$"CREATE TABLE {Table.PageRevision} (
page_id TEXT NOT NULL,
as_of TIMESTAMPTZ NOT NULL,
revision_text TEXT NOT NULL,
PRIMARY KEY (page_id, as_of))"
// Person table
if needsTable Table.Person then
Query.Definition.ensureTable Table.Person
Query.Definition.ensureKey Table.Person PostgreSQL
Query.Definition.ensureDocumentIndex Table.Person Optimized
// Post tables
if needsTable Table.Post then
Query.Definition.ensureTable Table.Post
Query.Definition.ensureKey Table.Post
Query.Definition.ensureIndexOn Table.Post "author" [ nameof Post.Empty.AuthorId ]
Query.Definition.ensureKey Table.Post PostgreSQL
Query.Definition.ensureIndexOn Table.Post "author" [ nameof Post.Empty.AuthorId ] PostgreSQL
Query.Definition.ensureIndexOn
Table.Post "permalink" [ nameof Post.Empty.WebLogId; nameof Post.Empty.Permalink ]
Table.Post "permalink" [ nameof Post.Empty.WebLogId; nameof Post.Empty.Permalink ] PostgreSQL
Query.Definition.ensureIndexOn
Table.Post
"status"
[ nameof Post.Empty.WebLogId; nameof Post.Empty.Status; nameof Post.Empty.UpdatedOn ]
PostgreSQL
$"CREATE INDEX idx_post_category ON {Table.Post} USING GIN ((data['{nameof Post.Empty.CategoryIds}']))"
$"CREATE INDEX idx_post_tag ON {Table.Post} USING GIN ((data['{nameof Post.Empty.Tags}']))"
if needsTable Table.PostRevision then
@@ -87,15 +94,15 @@ type PostgresData(log: ILogger<PostgresData>, ser: JsonSerializer) =
PRIMARY KEY (post_id, as_of))"
if needsTable Table.PostComment then
Query.Definition.ensureTable Table.PostComment
Query.Definition.ensureKey Table.PostComment
Query.Definition.ensureIndexOn Table.PostComment "post" [ nameof Comment.Empty.PostId ]
Query.Definition.ensureKey Table.PostComment PostgreSQL
Query.Definition.ensureIndexOn Table.PostComment "post" [ nameof Comment.Empty.PostId ] PostgreSQL
// Tag map table
if needsTable Table.TagMap then
Query.Definition.ensureTable Table.TagMap
Query.Definition.ensureKey Table.TagMap
Query.Definition.ensureKey Table.TagMap PostgreSQL
Query.Definition.ensureDocumentIndex Table.TagMap Optimized
// Uploaded file table
if needsTable Table.Upload then
$"CREATE TABLE {Table.Upload} (
@@ -106,13 +113,13 @@ type PostgresData(log: ILogger<PostgresData>, ser: JsonSerializer) =
data BYTEA NOT NULL)"
$"CREATE INDEX idx_upload_web_log ON {Table.Upload} (web_log_id)"
$"CREATE INDEX idx_upload_path ON {Table.Upload} (web_log_id, path)"
// Database version table
if needsTable Table.DbVersion then
$"CREATE TABLE {Table.DbVersion} (id TEXT NOT NULL PRIMARY KEY)"
$"INSERT INTO {Table.DbVersion} VALUES ('{Utils.Migration.currentDbVersion}')"
}
Configuration.dataSource ()
|> Sql.fromDataSource
|> Sql.executeTransactionAsync
@@ -127,13 +134,13 @@ type PostgresData(log: ILogger<PostgresData>, ser: JsonSerializer) =
|> Async.RunSynchronously
|> ignore
}
/// Set a specific database version
let setDbVersion version = backgroundTask {
do! Custom.nonQuery $"DELETE FROM db_version; INSERT INTO db_version VALUES ('%s{version}')" []
return version
}
/// Migrate from v2-rc2 to v2 (manual migration required)
let migrateV2Rc2ToV2 () = backgroundTask {
let! webLogs =
@@ -151,10 +158,11 @@ type PostgresData(log: ILogger<PostgresData>, ser: JsonSerializer) =
let tables =
[ Table.Category; Table.Page; Table.Post; Table.PostComment; Table.TagMap; Table.Theme; Table.WebLog
Table.WebLogUser ]
Utils.Migration.logStep log migration "Adding unique indexes on ID fields"
do! Custom.nonQuery (tables |> List.map Query.Definition.ensureKey |> String.concat "; ") []
do! Custom.nonQuery
(tables |> List.map (fun it -> Query.Definition.ensureKey it PostgreSQL) |> String.concat "; ") []
Utils.Migration.logStep log migration "Removing constraints"
let fkToDrop =
[ "page_revision", "page_revision_page_id_fkey"
@@ -174,39 +182,40 @@ type PostgresData(log: ILogger<PostgresData>, ser: JsonSerializer) =
|> List.map (fun (tbl, fk) -> $"ALTER TABLE {tbl} DROP CONSTRAINT {fk}")
|> String.concat "; ")
[]
Utils.Migration.logStep log migration "Dropping old indexes"
let toDrop =
[ "idx_category"; "page_author_idx"; "page_permalink_idx"; "page_web_log_idx"; "post_author_idx"
"post_category_idx"; "post_permalink_idx"; "post_status_idx"; "post_tag_idx"; "post_web_log_idx"
"post_comment_post_idx"; "idx_tag_map"; "idx_web_log"; "idx_web_log_user" ]
do! Custom.nonQuery (toDrop |> List.map (sprintf "DROP INDEX %s") |> String.concat "; ") []
Utils.Migration.logStep log migration "Dropping old ID columns"
do! Custom.nonQuery (tables |> List.map (sprintf "ALTER TABLE %s DROP COLUMN id") |> String.concat "; ") []
Utils.Migration.logStep log migration "Adding new indexes"
let newIdx =
[ yield! tables |> List.map Query.Definition.ensureKey
[ yield! tables |> List.map (fun it -> Query.Definition.ensureKey it PostgreSQL)
Query.Definition.ensureDocumentIndex Table.Category Optimized
Query.Definition.ensureDocumentIndex Table.TagMap Optimized
Query.Definition.ensureDocumentIndex Table.WebLog Optimized
Query.Definition.ensureDocumentIndex Table.WebLogUser Optimized
Query.Definition.ensureIndexOn Table.Page "author" [ nameof Page.Empty.AuthorId ]
Query.Definition.ensureIndexOn Table.Page "author" [ nameof Page.Empty.AuthorId ] PostgreSQL
Query.Definition.ensureIndexOn
Table.Page "permalink" [ nameof Page.Empty.WebLogId; nameof Page.Empty.Permalink ]
Query.Definition.ensureIndexOn Table.Post "author" [ nameof Post.Empty.AuthorId ]
Table.Page "permalink" [ nameof Page.Empty.WebLogId; nameof Page.Empty.Permalink ] PostgreSQL
Query.Definition.ensureIndexOn Table.Post "author" [ nameof Post.Empty.AuthorId ] PostgreSQL
Query.Definition.ensureIndexOn
Table.Post "permalink" [ nameof Post.Empty.WebLogId; nameof Post.Empty.Permalink ]
Table.Post "permalink" [ nameof Post.Empty.WebLogId; nameof Post.Empty.Permalink ] PostgreSQL
Query.Definition.ensureIndexOn
Table.Post
"status"
[ nameof Post.Empty.WebLogId; nameof Post.Empty.Status; nameof Post.Empty.UpdatedOn ]
PostgreSQL
$"CREATE INDEX idx_post_category ON {Table.Post} USING GIN ((data['{nameof Post.Empty.CategoryIds}']))"
$"CREATE INDEX idx_post_tag ON {Table.Post} USING GIN ((data['{nameof Post.Empty.Tags}']))"
Query.Definition.ensureIndexOn Table.PostComment "post" [ nameof Comment.Empty.PostId ] ]
Query.Definition.ensureIndexOn Table.PostComment "post" [ nameof Comment.Empty.PostId ] PostgreSQL ]
do! Custom.nonQuery (newIdx |> String.concat "; ") []
Utils.Migration.logStep log migration "Setting database to version 2.1.1"
return! setDbVersion "v2.1.1"
}
@@ -221,35 +230,51 @@ type PostgresData(log: ILogger<PostgresData>, ser: JsonSerializer) =
return! setDbVersion "v2.2"
}
/// Migrate from v2.2 to v3
let migrateV2point2ToV3 () = backgroundTask {
Utils.Migration.logStep log "v2.2 to v3" "Adding auto-OpenGraph flag to all web logs"
do! Patch.byFields Table.WebLog Any [ Field.Exists (nameof WebLog.Empty.Id) ] {| AutoOpenGraph = true |}
Utils.Migration.logStep log "v2.2 to v3" "Converting auto-htmx flag to choices"
do! Patch.byContains Table.WebLog {| AutoHtmx = true |} {| AutoHtmx = "Yes" |}
do! Patch.byContains Table.WebLog {| AutoHtmx = false |} {| AutoHtmx = "No" |}
Utils.Migration.logStep log "v2.2 to v3" "Setting database version to v3"
return! setDbVersion "v3"
}
/// Do required data migration between versions
let migrate version = backgroundTask {
let mutable v = defaultArg version ""
if v = "v2-rc2" then
if v = "v2-rc2" then
let! webLogs =
Custom.list
$"SELECT url_base, slug FROM {Table.WebLog}" []
(fun row -> row.string "url_base", row.string "slug")
Utils.Migration.backupAndRestoreRequired log "v2-rc2" "v2" webLogs
if v = "v2" then
let! ver = migrateV2ToV2point1point1 ()
v <- ver
if v = "v2.1.1" then
let! ver = migrateV2point1point1ToV2point2 ()
v <- ver
if v = "v2.2" then
let! ver = migrateV2point2ToV3 ()
v <- ver
if v <> Utils.Migration.currentDbVersion then
log.LogWarning $"Unknown database version; assuming {Utils.Migration.currentDbVersion}"
let! _ = setDbVersion Utils.Migration.currentDbVersion
()
}
interface IData with
member _.Category = PostgresCategoryData log
member _.Page = PostgresPageData log
member _.Person = PostgresPersonData log
member _.Post = PostgresPostData log
member _.TagMap = PostgresTagMapData log
member _.Theme = PostgresThemeData log
@@ -257,13 +282,13 @@ type PostgresData(log: ILogger<PostgresData>, ser: JsonSerializer) =
member _.Upload = PostgresUploadData log
member _.WebLog = PostgresWebLogData log
member _.WebLogUser = PostgresWebLogUserData log
member _.Serializer = ser
member _.StartUp () = backgroundTask {
log.LogTrace "PostgresData.StartUp"
do! ensureTables ()
let! version = Custom.single "SELECT id FROM db_version" [] (fun row -> row.string "id")
do! migrate version
}
+230 -123
View File
@@ -6,38 +6,41 @@ open RethinkDb.Driver
/// Functions to assist with retrieving data
module private RethinkHelpers =
/// Table names
[<RequireQualifiedAccess>]
module Table =
/// The category table
let Category = "Category"
/// The comment table
let Comment = "Comment"
/// The database version table
let DbVersion = "DbVersion"
/// The page table
let Page = "Page"
/// The person table
let Person = "Person"
/// The post table
let Post = "Post"
/// The tag map table
let TagMap = "TagMap"
/// The theme table
let Theme = "Theme"
/// The theme asset table
let ThemeAsset = "ThemeAsset"
/// The uploaded file table
let Upload = "Upload"
/// The web log table
let WebLog = "WebLog"
@@ -45,26 +48,27 @@ module private RethinkHelpers =
let WebLogUser = "WebLogUser"
/// A list of all tables
let all = [ Category; Comment; DbVersion; Page; Post; TagMap; Theme; ThemeAsset; Upload; WebLog; WebLogUser ]
let all =
[ Category; Comment; DbVersion; Page; Person; Post; TagMap; Theme; ThemeAsset; Upload; WebLog; WebLogUser ]
/// Index names for indexes not on a data item's name
[<RequireQualifiedAccess>]
module Index =
/// An index by web log ID and e-mail address
let LogOn = "LogOn"
/// An index by web log ID and uploaded file path
let WebLogAndPath = "WebLogAndPath"
/// An index by web log ID and mapped tag
let WebLogAndTag = "WebLogAndTag"
/// An index by web log ID and tag URL value
let WebLogAndUrl = "WebLogAndUrl"
/// Shorthand for the ReQL starting point
let r = RethinkDB.R
@@ -73,14 +77,14 @@ module private RethinkHelpers =
fun conn -> backgroundTask {
match! f conn with Some it when (prop it) = webLogId -> return Some it | _ -> return None
}
/// Get the first item from a list, or None if the list is empty
let tryFirst<'T> (f: Net.IConnection -> Task<'T list>) =
fun conn -> backgroundTask {
let! results = f conn
return results |> List.tryHead
}
/// Cast a strongly-typed list to an object list
let objList<'T> (objects: 'T list) = objects |> List.map (fun it -> it :> obj)
@@ -93,16 +97,16 @@ open RethinkHelpers
/// RethinkDB implementation of data functions for myWebLog
type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<RethinkDbData>) =
/// Match theme asset IDs by their prefix (the theme ID)
let matchAssetByThemeId themeId =
let keyPrefix = $"^{themeId}/"
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].Merge(r.HashMap(nameof ThemeTemplate.Empty.Text, "")) |}
/// Ensure field indexes exist, as well as special indexes for selected tables
let ensureIndexes table fields = backgroundTask {
let! indexes = rethink<string list> { withTable table; indexList; result; withRetryOnce conn }
@@ -180,13 +184,13 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
}
do! rethink { withTable table; indexWait; result; withRetryDefault; ignoreResult conn }
}
/// The batch size for restoration methods
let restoreBatchSize = 100
/// A value to use when files need to be retrieved without their data
let emptyFile = r.Binary(Array.Empty<byte>())
/// Delete assets for the given theme ID
let deleteAssetsByTheme themeId = rethink {
withTable Table.ThemeAsset
@@ -194,7 +198,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
delete
write; withRetryDefault; ignoreResult conn
}
/// Set a specific database version
let setDbVersion (version: string) = backgroundTask {
do! rethink {
@@ -208,7 +212,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
write; withRetryOnce; ignoreResult conn
}
}
/// Migrate from v2-rc1 to v2-rc2
let migrateV2Rc1ToV2Rc2 () = backgroundTask {
let logStep = Utils.Migration.logStep log "v2-rc1 to v2-rc2"
@@ -233,11 +237,11 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
update [ nameof WebLog.Empty.RedirectRules, [] :> obj ]
write; withRetryOnce; ignoreResult conn
}
Utils.Migration.logStep log "v2 to v2.1" "Setting database version to v2.1"
do! setDbVersion "v2.1"
}
/// Migrate from v2.1 to v2.1.1
let migrateV2point1ToV2point1point1 () = backgroundTask {
Utils.Migration.logStep log "v2.1 to v2.1.1" "Setting database version; no migration required"
@@ -256,10 +260,35 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
do! setDbVersion "v2.2"
}
/// Migrate from v2.2 to v3
let migrateV2point2ToV3 () = backgroundTask {
Utils.Migration.logStep log "v2.2 to v3" "Adding auto-OpenGraph flag to all web logs"
do! rethink {
withTable Table.WebLog
update [ nameof WebLog.Empty.AutoOpenGraph, true :> obj ]
write; withRetryOnce; ignoreResult conn
}
Utils.Migration.logStep log "v2.2 to v3" "Converting auto-htmx flag to choices"
do! rethink {
withTable Table.WebLog
filter (nameof WebLog.Empty.AutoHtmx) true
update [ nameof WebLog.Empty.AutoHtmx, "Yes" :> obj ]
write; withRetryOnce; ignoreResult conn
}
do! rethink {
withTable Table.WebLog
filter (nameof WebLog.Empty.AutoHtmx) false
update [ nameof WebLog.Empty.AutoHtmx, "No" :> obj ]
write; withRetryOnce; ignoreResult conn
}
Utils.Migration.logStep log "v2.2 to v3" "Setting database version to v3"
do! setDbVersion "v3"
}
/// Migrate data between versions
let migrate version = backgroundTask {
let mutable v = defaultArg version ""
if v = "v2-rc1" then
do! migrateV2Rc1ToV2Rc2 ()
v <- "v2-rc2"
@@ -267,38 +296,42 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
if v = "v2-rc2" then
do! migrateV2Rc2ToV2 ()
v <- "v2"
if v = "v2" then
do! migrateV2ToV2point1 ()
v <- "v2.1"
if v = "v2.1" then
do! migrateV2point1ToV2point1point1 ()
v <- "v2.1.1"
if v = "v2.1.1" then
do! migrateV2point1point1ToV2point2 ()
v <- "v2.2"
if v = "v2.2" then
do! migrateV2point2ToV3 ()
v <- "v3"
if v <> Utils.Migration.currentDbVersion then
log.LogWarning $"Unknown database version; assuming {Utils.Migration.currentDbVersion}"
do! setDbVersion Utils.Migration.currentDbVersion
}
/// The connection for this instance
member _.Conn = conn
interface IData with
member _.Category = {
new ICategoryData with
member _.Add cat = rethink {
withTable Table.Category
insert cat
write; withRetryDefault; ignoreResult conn
}
member _.CountAll webLogId = rethink<int> {
withTable Table.Category
getAll [ webLogId ] (nameof Category.Empty.WebLogId)
@@ -313,7 +346,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
count
result; withRetryDefault conn
}
member _.FindAllForView webLogId = backgroundTask {
let! cats = rethink<Category list> {
withTable Table.Category
@@ -353,7 +386,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
|> Option.defaultValue 0 })
|> Array.ofSeq
}
member _.FindById catId webLogId =
rethink<Category> {
withTable Table.Category
@@ -361,13 +394,13 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
resultOption; withRetryOptionDefault
}
|> verifyWebLog webLogId _.WebLogId <| conn
member _.FindByWebLog webLogId = rethink<Category list> {
withTable Table.Category
getAll [ webLogId ] (nameof Category.Empty.WebLogId)
result; withRetryDefault conn
}
member this.Delete catId webLogId = backgroundTask {
match! this.FindById catId webLogId with
| Some cat ->
@@ -394,7 +427,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
{| CategoryIds =
row[nameof Post.Empty.CategoryIds].CoerceTo("array")
.SetDifference(r.Array(catId)) |} :> obj)
write; withRetryDefault; ignoreResult conn
write; withRetryDefault; ignoreResult conn
}
// Delete the category itself
do! rethink {
@@ -406,7 +439,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
return if children = 0 then CategoryDeleted else ReassignedChildCategories
| None -> return CategoryNotFound
}
member _.Restore cats = backgroundTask {
for batch in cats |> List.chunkBySize restoreBatchSize do
do! rethink {
@@ -415,7 +448,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
write; withRetryOnce; ignoreResult conn
}
}
member _.Update cat = rethink {
withTable Table.Category
get cat.Id
@@ -427,10 +460,10 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
write; withRetryDefault; ignoreResult conn
}
}
member _.Page = {
new IPageData with
member _.Add page = rethink {
withTable Table.Page
insert page
@@ -447,7 +480,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
orderByFunc (fun row -> row[nameof Page.Empty.Title].Downcase() :> obj)
result; withRetryDefault conn
}
member _.CountAll webLogId = rethink<int> {
withTable Table.Page
getAll [ webLogId ] (nameof Page.Empty.WebLogId)
@@ -473,7 +506,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
}
return result.Deleted > 0UL
}
member _.FindById pageId webLogId =
rethink<Page list> {
withTable Table.Page
@@ -495,7 +528,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
result; withRetryDefault
}
|> tryFirst <| conn
member _.FindCurrentPermalink permalinks webLogId = backgroundTask {
let! result =
(rethink<Page list> {
@@ -509,7 +542,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
|> tryFirst) conn
return result |> Option.map _.Permalink
}
member _.FindFullById pageId webLogId =
rethink<Page> {
withTable Table.Page
@@ -517,13 +550,13 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
resultOption; withRetryOptionDefault
}
|> verifyWebLog webLogId _.WebLogId <| conn
member _.FindFullByWebLog webLogId = rethink<Page> {
withTable Table.Page
getAll [ webLogId ] (nameof Page.Empty.WebLogId)
resultCursor; withRetryCursorDefault; toList conn
}
member _.FindListed webLogId = rethink<Page list> {
withTable Table.Page
getAll [ webLogId ] (nameof Page.Empty.WebLogId)
@@ -543,10 +576,10 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
.With(nameof Page.Empty.Revisions, [||]))
orderByFunc (fun row -> row[nameof Page.Empty.Title].Downcase())
skip ((pageNbr - 1) * 25)
limit 25
limit 26
result; withRetryDefault conn
}
member _.Restore pages = backgroundTask {
for batch in pages |> List.chunkBySize restoreBatchSize do
do! rethink {
@@ -555,7 +588,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
write; withRetryOnce; ignoreResult conn
}
}
member _.Update page = rethink {
withTable Table.Page
get page.Id
@@ -572,7 +605,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
]
write; withRetryDefault; ignoreResult conn
}
member this.UpdatePriorPermalinks pageId webLogId permalinks = backgroundTask {
match! this.FindById pageId webLogId with
| Some _ ->
@@ -587,15 +620,81 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
}
}
member _.Person = {
new IPersonData with
member _.Add person = rethink {
withTable Table.Person
insert person
write; withRetryDefault; ignoreResult conn
}
member _.Delete personId webLogId = backgroundTask {
// TODO: also remove this person from assignments in episodes and podcasts
let! result = rethink<Model.Result> {
withTable Table.Person
getAll [ personId ]
filter (fun row -> row[nameof Person.Empty.WebLogId].Eq webLogId :> obj)
delete
write; withRetryDefault conn
}
return result.Deleted > 0UL
}
member _.FindByAssignment assignments = rethink {
withTable Table.Person
getAll (assignments |> List.map _.PersonId)
orderBy (nameof Person.Empty.Name)
result; withRetryDefault conn
}
member _.FindById personId webLogId =
rethink<Person list> {
withTable Table.Person
getAll [ personId ]
filter (nameof Person.Empty.WebLogId) webLogId
result; withRetryDefault
}
|> tryFirst <| conn
member _.FindByWebLog webLogId = rethink<Person list> {
withTable Table.Person
getAll [ webLogId ] (nameof Person.Empty.WebLogId)
orderBy (nameof Person.Empty.Name)
result; withRetryDefault conn
}
member _.Restore persons = backgroundTask {
for batch in persons |> List.chunkBySize restoreBatchSize do
do! rethink {
withTable Table.Person
insert batch
write; withRetryOnce; ignoreResult conn
}
}
member this.Update person = backgroundTask {
match! this.FindById person.Id person.WebLogId with
| Some _ ->
do! rethink {
withTable Table.Person
get person.Id
replace person
write; withRetryDefault; ignoreResult conn
}
| None -> ()
}
}
member _.Post = {
new IPostData with
member _.Add post = rethink {
withTable Table.Post
insert post
write; withRetryDefault; ignoreResult conn
}
member _.CountByStatus status webLogId = rethink<int> {
withTable Table.Post
getAll [ webLogId ] (nameof Post.Empty.WebLogId)
@@ -614,7 +713,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
}
return result.Deleted > 0UL
}
member _.FindById postId webLogId =
rethink<Post list> {
withTable Table.Post
@@ -625,7 +724,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
result; withRetryDefault
}
|> tryFirst <| conn
member _.FindByPermalink permalink webLogId =
rethink<Post list> {
withTable Table.Post
@@ -636,7 +735,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
result; withRetryDefault
}
|> tryFirst <| conn
member _.FindFullById postId webLogId =
rethink<Post> {
withTable Table.Post
@@ -658,13 +757,13 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
|> tryFirst) conn
return result |> Option.map _.Permalink
}
member _.FindFullByWebLog webLogId = rethink<Post> {
withTable Table.Post
getAll [ webLogId ] (nameof Post.Empty.WebLogId)
resultCursor; withRetryCursorDefault; toList conn
}
member _.FindPageOfCategorizedPosts webLogId categoryIds pageNbr postsPerPage = rethink<Post list> {
withTable Table.Post
getAll (objList categoryIds) (nameof Post.Empty.CategoryIds)
@@ -678,7 +777,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
limit (postsPerPage + 1)
result; withRetryDefault conn
}
member _.FindPageOfPosts webLogId pageNbr postsPerPage = rethink<Post list> {
withTable Table.Post
getAll [ webLogId ] (nameof Post.Empty.WebLogId)
@@ -703,7 +802,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
limit (postsPerPage + 1)
result; withRetryDefault conn
}
member _.FindPageOfTaggedPosts webLogId tag pageNbr postsPerPage = rethink<Post list> {
withTable Table.Post
getAll [ tag ] (nameof Post.Empty.Tags)
@@ -716,7 +815,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
limit (postsPerPage + 1)
result; withRetryDefault conn
}
member _.FindSurroundingPosts webLogId publishedOn = backgroundTask {
let! older =
rethink<Post list> {
@@ -744,7 +843,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
|> tryFirst <| conn
return older, newer
}
member _.Restore pages = backgroundTask {
for batch in pages |> List.chunkBySize restoreBatchSize do
do! rethink {
@@ -753,7 +852,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
write; withRetryOnce; ignoreResult conn
}
}
member this.Update post = backgroundTask {
match! this.FindById post.Id post.WebLogId with
| Some _ ->
@@ -779,10 +878,10 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
| None -> return false
}
}
member _.TagMap = {
new ITagMapData with
member _.Delete tagMapId webLogId = backgroundTask {
let! result = rethink<Model.Result> {
withTable Table.TagMap
@@ -793,7 +892,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
}
return result.Deleted > 0UL
}
member _.FindById tagMapId webLogId =
rethink<TagMap> {
withTable Table.TagMap
@@ -801,7 +900,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
resultOption; withRetryOptionDefault
}
|> verifyWebLog webLogId _.WebLogId <| conn
member _.FindByUrlValue urlValue webLogId =
rethink<TagMap list> {
withTable Table.TagMap
@@ -810,7 +909,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
result; withRetryDefault
}
|> tryFirst <| conn
member _.FindByWebLog webLogId = rethink<TagMap list> {
withTable Table.TagMap
between [| webLogId :> obj; r.Minval() |] [| webLogId :> obj; r.Maxval() |]
@@ -818,13 +917,13 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
orderBy (nameof TagMap.Empty.Tag)
result; withRetryDefault conn
}
member _.FindMappingForTags tags webLogId = rethink<TagMap list> {
withTable Table.TagMap
getAll (tags |> List.map (fun tag -> [| webLogId :> obj; tag |] :> obj)) Index.WebLogAndTag
result; withRetryDefault conn
}
member _.Restore tagMaps = backgroundTask {
for batch in tagMaps |> List.chunkBySize restoreBatchSize do
do! rethink {
@@ -833,7 +932,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
write; withRetryOnce; ignoreResult conn
}
}
member _.Save tagMap = rethink {
withTable Table.TagMap
get tagMap.Id
@@ -841,10 +940,10 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
write; withRetryDefault; ignoreResult conn
}
}
member _.Theme = {
new IThemeData with
member _.All () = rethink<Theme list> {
withTable Table.Theme
filter (fun row -> row[nameof Theme.Empty.Id].Ne "admin" :> obj)
@@ -852,7 +951,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
orderBy (nameof Theme.Empty.Id)
result; withRetryDefault conn
}
member _.Exists themeId = backgroundTask {
let! count = rethink<int> {
withTable Table.Theme
@@ -862,13 +961,13 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
}
return count > 0
}
member _.FindById themeId = rethink<Theme> {
withTable Table.Theme
get themeId
resultOption; withRetryOptionDefault conn
}
member _.FindByIdWithoutText themeId =
rethink<Theme list> {
withTable Table.Theme
@@ -877,7 +976,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
result; withRetryDefault
}
|> tryFirst <| conn
member this.Delete themeId = backgroundTask {
match! this.FindByIdWithoutText themeId with
| Some _ ->
@@ -891,7 +990,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
return true
| None -> return false
}
member _.Save theme = rethink {
withTable Table.Theme
get theme.Id
@@ -899,37 +998,37 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
write; withRetryDefault; ignoreResult conn
}
}
member _.ThemeAsset = {
new IThemeAssetData with
member _.All () = rethink<ThemeAsset list> {
withTable Table.ThemeAsset
merge (r.HashMap(nameof ThemeAsset.Empty.Data, emptyFile))
result; withRetryDefault conn
}
member _.DeleteByTheme themeId = deleteAssetsByTheme themeId
member _.FindById assetId = rethink<ThemeAsset> {
withTable Table.ThemeAsset
get assetId
resultOption; withRetryOptionDefault conn
}
member _.FindByTheme themeId = rethink<ThemeAsset list> {
withTable Table.ThemeAsset
filter (matchAssetByThemeId themeId)
merge (r.HashMap(nameof ThemeAsset.Empty.Data, emptyFile))
result; withRetryDefault conn
}
member _.FindByThemeWithData themeId = rethink<ThemeAsset> {
withTable Table.ThemeAsset
filter (matchAssetByThemeId themeId)
resultCursor; withRetryCursorDefault; toList conn
}
member _.Save asset = rethink {
withTable Table.ThemeAsset
get asset.Id
@@ -937,16 +1036,16 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
write; withRetryDefault; ignoreResult conn
}
}
member _.Upload = {
new IUploadData with
member _.Add upload = rethink {
withTable Table.Upload
insert upload
write; withRetryDefault; ignoreResult conn
}
member _.Delete uploadId webLogId = backgroundTask {
let! upload =
rethink<Upload> {
@@ -966,7 +1065,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
return Ok (string up.Path)
| None -> return Result.Error $"Upload ID {uploadId} not found"
}
member _.FindByPath path webLogId =
rethink<Upload> {
withTable Table.Upload
@@ -974,7 +1073,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
resultCursor; withRetryCursorDefault; toList
}
|> tryFirst <| conn
member _.FindByWebLog webLogId = rethink<Upload> {
withTable Table.Upload
between [| webLogId :> obj; r.Minval() |] [| webLogId :> obj; r.Maxval() |]
@@ -982,14 +1081,14 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
merge (r.HashMap(nameof Upload.Empty.Data, emptyFile))
resultCursor; withRetryCursorDefault; toList conn
}
member _.FindByWebLogWithData webLogId = rethink<Upload> {
withTable Table.Upload
between [| webLogId :> obj; r.Minval() |] [| webLogId :> obj; r.Maxval() |]
[ Index Index.WebLogAndPath ]
resultCursor; withRetryCursorDefault; toList conn
}
member _.Restore uploads = backgroundTask {
// Files can be large; we'll do 5 at a time
for batch in uploads |> List.chunkBySize 5 do
@@ -1000,21 +1099,21 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
}
}
}
member _.WebLog = {
new IWebLogData with
member _.Add webLog = rethink {
withTable Table.WebLog
insert webLog
write; withRetryOnce; ignoreResult conn
}
member _.All () = rethink<WebLog list> {
withTable Table.WebLog
result; withRetryDefault conn
}
member _.Delete webLogId = backgroundTask {
// Comments should be deleted by post IDs
let! thePostIds = rethink<{| Id: string |} list> {
@@ -1061,7 +1160,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
write; withRetryOnce; ignoreResult conn
}
}
member _.FindByHost url =
rethink<WebLog list> {
withTable Table.WebLog
@@ -1076,21 +1175,21 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
get webLogId
resultOption; withRetryOptionDefault conn
}
member _.UpdateRedirectRules webLog = rethink {
withTable Table.WebLog
get webLog.Id
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 ]
write; withRetryDefault; ignoreResult conn
}
member _.UpdateSettings webLog = rethink {
withTable Table.WebLog
get webLog.Id
@@ -1108,16 +1207,16 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
write; withRetryDefault; ignoreResult conn
}
}
member _.WebLogUser = {
new IWebLogUserData with
member _.Add user = rethink {
withTable Table.WebLogUser
insert user
write; withRetryDefault; ignoreResult conn
}
member _.FindById userId webLogId =
rethink<WebLogUser> {
withTable Table.WebLogUser
@@ -1125,7 +1224,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
resultOption; withRetryOptionDefault
}
|> verifyWebLog webLogId _.WebLogId <| conn
member this.Delete userId webLogId = backgroundTask {
match! this.FindById userId webLogId with
| Some _ ->
@@ -1155,7 +1254,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
return Ok true
| None -> return Result.Error "User does not exist"
}
member _.FindByEmail email webLogId =
rethink<WebLogUser list> {
withTable Table.WebLogUser
@@ -1164,14 +1263,14 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
result; withRetryDefault
}
|> tryFirst <| conn
member _.FindByWebLog webLogId = rethink<WebLogUser list> {
withTable Table.WebLogUser
getAll [ webLogId ] (nameof WebLogUser.Empty.WebLogId)
orderByFunc (fun row -> row[nameof WebLogUser.Empty.PreferredName].Downcase())
result; withRetryDefault conn
}
member _.FindNames webLogId userIds = backgroundTask {
let! users = rethink<WebLogUser list> {
withTable Table.WebLogUser
@@ -1181,7 +1280,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
}
return users |> List.map (fun u -> { Name = string u.Id; Value = u.DisplayName })
}
member _.Restore users = backgroundTask {
for batch in users |> List.chunkBySize restoreBatchSize do
do! rethink {
@@ -1190,7 +1289,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
write; withRetryOnce; ignoreResult conn
}
}
member this.SetLastSeen userId webLogId = backgroundTask {
match! this.FindById userId webLogId with
| Some _ ->
@@ -1202,7 +1301,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
}
| None -> ()
}
member _.Update user = rethink {
withTable Table.WebLogUser
get user.Id
@@ -1218,39 +1317,47 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
write; withRetryDefault; ignoreResult conn
}
}
member _.Serializer =
Net.Converter.Serializer
member _.StartUp () = backgroundTask {
let! dbs = rethink<string list> { dbList; result; withRetryOnce conn }
if not (dbs |> List.contains config.Database) then
log.LogInformation $"Creating database {config.Database}..."
do! rethink { dbCreate config.Database; write; withRetryOnce; ignoreResult conn }
let! tables = rethink<string list> { tableList; result; withRetryOnce conn }
for tbl in Table.all do
if not (tables |> List.contains tbl) then
log.LogInformation $"Creating table {tbl}..."
do! rethink { tableCreate tbl [ PrimaryKey "Id" ]; write; withRetryOnce; ignoreResult conn }
if not (List.contains Table.DbVersion tables) then
// Version table added in v2-rc2; this will flag that migration to be run
if List.isEmpty tables then
// New install; set version to current version
do! rethink {
withTable Table.DbVersion
insert {| Id = Utils.Migration.currentDbVersion |}
write; withRetryOnce; ignoreResult conn
}
elif not (List.contains Table.DbVersion tables) then
// Other tables, but not version, added in v2-rc2; this will flag that migration to be run
do! rethink {
withTable Table.DbVersion
insert {| Id = "v2-rc1" |}
write; withRetryOnce; ignoreResult conn
}
do! ensureIndexes Table.Category [ nameof Category.Empty.WebLogId ]
do! ensureIndexes Table.Comment [ nameof Comment.Empty.PostId ]
do! ensureIndexes Table.Page [ nameof Page.Empty.WebLogId; nameof Page.Empty.AuthorId ]
do! ensureIndexes Table.Person [ nameof Person.Empty.WebLogId ]
do! ensureIndexes Table.Post [ nameof Post.Empty.WebLogId; nameof Post.Empty.AuthorId ]
do! ensureIndexes Table.TagMap []
do! ensureIndexes Table.Upload []
do! ensureIndexes Table.WebLog [ nameof WebLog.Empty.UrlBase ]
do! ensureIndexes Table.WebLogUser [ nameof WebLogUser.Empty.WebLogId ]
let! version = rethink<{| Id: string |} list> {
withTable Table.DbVersion
limit 1
+29 -32
View File
@@ -16,22 +16,23 @@ type SQLiteCategoryData(conn: SqliteConnection, ser: JsonSerializer, log: ILogge
let parentIdField = nameof Category.Empty.ParentId
/// Count all categories for the given web log
let countAll webLogId =
let countAll webLogId = backgroundTask {
log.LogTrace "Category.countAll"
Document.countByWebLog Table.Category webLogId conn
let! count = conn.countByFields Table.Category Any [ webLogField webLogId ]
return int count
}
/// Count all top-level categories for the given web log
let countTopLevel webLogId =
let countTopLevel webLogId = backgroundTask {
log.LogTrace "Category.countTopLevel"
conn.customScalar
$"{Document.Query.countByWebLog Table.Category} AND data ->> '{parentIdField}' IS NULL"
[ webLogParam webLogId ]
(toCount >> int)
let! count = conn.countByFields Table.Category All [ webLogField webLogId; Field.NotExists parentIdField ]
return int count
}
/// Find all categories for the given web log
let findByWebLog webLogId =
log.LogTrace "Category.findByWebLog"
Document.findByWebLog<Category> Table.Category webLogId conn
conn.findByFields<Category> Table.Category Any [ webLogField webLogId ]
/// Retrieve all categories for the given web log in a DotLiquid-friendly format
let findAllForView webLogId = backgroundTask {
@@ -42,20 +43,18 @@ type SQLiteCategoryData(conn: SqliteConnection, ser: JsonSerializer, log: ILogge
ordered
|> Seq.map (fun it -> backgroundTask {
// Parent category post counts include posts in subcategories
let catSql, catParams =
let childField =
ordered
|> Seq.filter (fun cat -> cat.ParentNames |> Array.contains it.Name)
|> Seq.map _.Id
|> Seq.append (Seq.singleton it.Id)
|> List.ofSeq
|> inJsonArray Table.Post (nameof Post.Empty.CategoryIds) "catId"
let query = $"""
SELECT COUNT(DISTINCT data ->> '{nameof Post.Empty.Id}')
FROM {Table.Post}
WHERE {Document.Query.whereByWebLog}
AND {Query.whereByField (Field.EQ (nameof Post.Empty.Status) "") $"'{string Published}'"}
AND {catSql}"""
let! postCount = conn.customScalar query (webLogParam webLogId :: catParams) toCount
|> Field.InArray (nameof Post.Empty.CategoryIds) Table.Post
let fields =
[ webLogField webLogId; Field.Equal (nameof Post.Empty.Status) (string Published); childField ]
let query =
(Query.statementWhere (Query.count Table.Post) (Query.whereByFields All fields))
.Replace("(*)", $"(DISTINCT data->>'{nameof Post.Empty.Id}')")
let! postCount = conn.customScalar query (addFieldParams fields []) toCount
return it.Id, int postCount
})
|> Task.WhenAll
@@ -69,9 +68,9 @@ type SQLiteCategoryData(conn: SqliteConnection, ser: JsonSerializer, log: ILogge
}
/// Find a category by its ID for the given web log
let findById catId webLogId =
let findById (catId: CategoryId) webLogId =
log.LogTrace "Category.findById"
Document.findByIdAndWebLog<CategoryId, Category> Table.Category catId webLogId conn
conn.findFirstByFields<Category> Table.Category All [ idField catId; webLogField webLogId ]
/// Delete a category
let delete catId webLogId = backgroundTask {
@@ -79,24 +78,22 @@ type SQLiteCategoryData(conn: SqliteConnection, ser: JsonSerializer, log: ILogge
match! findById catId webLogId with
| Some cat ->
// Reassign any children to the category's parent category
let! children = conn.countByField Table.Category (Field.EQ parentIdField (string catId))
let! children = conn.countByFields Table.Category Any [ Field.Equal parentIdField (string catId) ]
if children > 0L then
let parent = Field.EQ parentIdField (string catId)
let parent = [ Field.Equal parentIdField (string catId) ]
match cat.ParentId with
| Some _ -> do! conn.patchByField Table.Category parent {| ParentId = cat.ParentId |}
| None -> do! conn.removeFieldsByField Table.Category parent [ parentIdField ]
| Some _ -> do! conn.patchByFields Table.Category Any parent {| ParentId = cat.ParentId |}
| None -> do! conn.removeFieldsByFields Table.Category Any parent [ parentIdField ]
// Delete the category off all posts where it is assigned, and the category itself
let catIdField = nameof Post.Empty.CategoryIds
let fields = [ webLogField webLogId; Field.InArray catIdField Table.Post [ string catId ] ]
let query =
(Query.statementWhere (Query.find Table.Post) (Query.whereByFields All fields))
.Replace("SELECT data", $"SELECT data->>'{nameof Post.Empty.Id}', data->'{catIdField}'")
let! posts =
conn.customList
$"SELECT data ->> '{nameof Post.Empty.Id}', data -> '{catIdField}'
FROM {Table.Post}
WHERE {Document.Query.whereByWebLog}
AND EXISTS
(SELECT 1
FROM json_each({Table.Post}.data -> '{catIdField}')
WHERE json_each.value = @id)"
[ idParam catId; webLogParam webLogId ]
query
(addFieldParams fields [])
(fun rdr -> rdr.GetString 0, Utils.deserialize<string list> ser (rdr.GetString 1))
for postId, cats in posts do
do! conn.patchById
+58 -121
View File
@@ -5,55 +5,58 @@ module MyWebLog.Data.SQLite.SQLiteHelpers
/// The table names used in the SQLite implementation
[<RequireQualifiedAccess>]
module Table =
/// Categories
[<Literal>]
let Category = "category"
/// Database Version
[<Literal>]
let DbVersion = "db_version"
/// Pages
[<Literal>]
let Page = "page"
/// Page Revisions
[<Literal>]
let PageRevision = "page_revision"
[<Literal>]
let Person = "person"
/// Posts
[<Literal>]
let Post = "post"
/// Post Comments
[<Literal>]
let PostComment = "post_comment"
/// Post Revisions
[<Literal>]
let PostRevision = "post_revision"
/// Tag/URL Mappings
[<Literal>]
let TagMap = "tag_map"
/// Themes
[<Literal>]
let Theme = "theme"
/// Theme Assets
[<Literal>]
let ThemeAsset = "theme_asset"
/// Uploads
[<Literal>]
let Upload = "upload"
/// Web Logs
[<Literal>]
let WebLog = "web_log"
/// Users
[<Literal>]
let WebLogUser = "web_log_user"
@@ -82,111 +85,78 @@ let instantParam =
let maybeInstant =
Option.map instantParam >> maybe
/// Create the SQL and parameters for an EXISTS applied to a JSON array
let inJsonArray<'T> table jsonField paramName (items: 'T list) =
if List.isEmpty items then "", []
else
let mutable idx = 0
items
|> List.skip 1
|> List.fold (fun (itemS, itemP) it ->
idx <- idx + 1
$"{itemS}, @%s{paramName}{idx}", (SqliteParameter($"@%s{paramName}{idx}", string it) :: itemP))
(Seq.ofList items
|> Seq.map (fun it -> $"(@%s{paramName}0", [ SqliteParameter($"@%s{paramName}0", string it) ])
|> Seq.head)
|> function
sql, ps ->
$"EXISTS (SELECT 1 FROM json_each(%s{table}.data, '$.%s{jsonField}') WHERE value IN {sql}))", ps
/// Create the SQL and parameters for an IN clause
let inClause<'T> colNameAndPrefix paramName (valueFunc: 'T -> string) (items: 'T list) =
if List.isEmpty items then "", []
else
let mutable idx = 0
items
|> List.skip 1
|> List.fold (fun (itemS, itemP) it ->
idx <- idx + 1
$"{itemS}, @%s{paramName}{idx}", (SqliteParameter ($"@%s{paramName}{idx}", valueFunc it) :: itemP))
(Seq.ofList items
|> Seq.map (fun it ->
$"%s{colNameAndPrefix} IN (@%s{paramName}0", [ SqliteParameter ($"@%s{paramName}0", valueFunc it) ])
|> Seq.head)
|> function sql, ps -> $"{sql})", ps
/// Functions to map domain items from a data reader
module Map =
open System.IO
/// Get a boolean value from a data reader
let getBoolean col (rdr: SqliteDataReader) = rdr.GetBoolean(rdr.GetOrdinal col)
/// Get a date/time value from a data reader
let getDateTime col (rdr: SqliteDataReader) = rdr.GetDateTime(rdr.GetOrdinal col)
/// Get a Guid value from a data reader
let getGuid col (rdr: SqliteDataReader) = rdr.GetGuid(rdr.GetOrdinal col)
/// Get an int value from a data reader
let getInt col (rdr: SqliteDataReader) = rdr.GetInt32(rdr.GetOrdinal col)
/// Get a long (64-bit int) value from a data reader
let getLong col (rdr: SqliteDataReader) = rdr.GetInt64(rdr.GetOrdinal col)
/// Get a BLOB stream value from a data reader
let getStream col (rdr: SqliteDataReader) = rdr.GetStream(rdr.GetOrdinal col)
/// Get a string value from a data reader
let getString col (rdr: SqliteDataReader) = rdr.GetString(rdr.GetOrdinal col)
/// Parse an Instant from the given value
let parseInstant value =
match InstantPattern.General.Parse value with
| it when it.Success -> it.Value
| it -> raise it.Exception
/// Get an Instant value from a data reader
let getInstant col rdr =
getString col rdr |> parseInstant
/// Get a timespan value from a data reader
let getTimeSpan col (rdr: SqliteDataReader) = rdr.GetTimeSpan(rdr.GetOrdinal col)
/// Get a possibly null boolean value from a data reader
let tryBoolean col (rdr: SqliteDataReader) =
if rdr.IsDBNull(rdr.GetOrdinal col) then None else Some (getBoolean col rdr)
/// Get a possibly null date/time value from a data reader
let tryDateTime col (rdr: SqliteDataReader) =
if rdr.IsDBNull(rdr.GetOrdinal col) then None else Some (getDateTime col rdr)
/// Get a possibly null Guid value from a data reader
let tryGuid col (rdr: SqliteDataReader) =
if rdr.IsDBNull(rdr.GetOrdinal col) then None else Some (getGuid col rdr)
/// Get a possibly null int value from a data reader
let tryInt col (rdr: SqliteDataReader) =
if rdr.IsDBNull(rdr.GetOrdinal col) then None else Some (getInt col rdr)
/// Get a possibly null string value from a data reader
let tryString col (rdr: SqliteDataReader) =
if rdr.IsDBNull(rdr.GetOrdinal col) then None else Some (getString col rdr)
/// Get a possibly null timespan value from a data reader
let tryTimeSpan col (rdr: SqliteDataReader) =
if rdr.IsDBNull(rdr.GetOrdinal col) then None else Some (getTimeSpan col rdr)
/// Create a permalink from the current row in the given data reader
let toPermalink rdr = getString "permalink" rdr |> Permalink
/// 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 }
/// Create a theme asset from the current row in the given data reader
let toThemeAsset includeData rdr : ThemeAsset =
let assetData =
@@ -200,7 +170,7 @@ module Map =
{ Id = ThemeAssetId (ThemeId (getString "theme_id" rdr), getString "path" rdr)
UpdatedOn = getInstant "updated_on" rdr
Data = assetData }
/// Create an uploaded file from the current row in the given data reader
let toUpload includeData rdr : Upload =
let data =
@@ -218,90 +188,57 @@ module Map =
Data = data }
open BitBadger.Documents
/// Create a named parameter
let sqlParam name (value: obj) =
SqliteParameter(name, value)
/// Create a web log ID parameter
let webLogParam (webLogId: WebLogId) =
sqlParam "@webLogId" (string webLogId)
/// Create a field for an ID value
let idField<'T> (idValue: 'T) =
{ Field.Equal "Id" (string idValue) with ParameterName = Some "@id" }
/// Create a web log field
let webLogField (webLogId: WebLogId) =
{ Field.Equal "WebLogId" (string webLogId) with ParameterName = Some "@webLogId" }
open BitBadger.Documents
open BitBadger.Documents.Sqlite
open BitBadger.Documents.Sqlite.WithConn
/// Functions for manipulating documents
module Document =
/// Queries to assist with document manipulation
module Query =
/// Fragment to add a web log ID condition to a WHERE clause (parameter @webLogId)
let whereByWebLog =
Query.whereByField (Field.EQ "WebLogId" "") "@webLogId"
/// A SELECT query to count documents for a given web log ID
let countByWebLog table =
$"{Query.Count.all table} WHERE {whereByWebLog}"
/// A query to select from a table by the document's ID and its web log ID
let selectByIdAndWebLog table =
$"{Query.Find.byId table} AND {whereByWebLog}"
/// A query to select from a table by its web log ID
let selectByWebLog table =
$"{Query.selectFromTable table} WHERE {whereByWebLog}"
/// Count documents for the given web log ID
let countByWebLog table (webLogId: WebLogId) conn = backgroundTask {
let! count = Count.byField table (Field.EQ "WebLogId" (string webLogId)) conn
return int count
}
/// Find a document by its ID and web log ID
let findByIdAndWebLog<'TKey, 'TDoc> table (key: 'TKey) webLogId conn =
Custom.single (Query.selectByIdAndWebLog table) [ idParam key; webLogParam webLogId ] fromData<'TDoc> conn
/// Find documents for the given web log
let findByWebLog<'TDoc> table (webLogId: WebLogId) conn =
Find.byField<'TDoc> table (Field.EQ "WebLogId" (string webLogId)) conn
/// Functions to support revisions
module Revisions =
/// Find all revisions for the given entity
let findByEntityId<'TKey> revTable entityTable (key: 'TKey) conn =
Custom.list
let findByEntityId<'TKey> revTable entityTable (key: 'TKey) (conn: SqliteConnection) =
conn.customList
$"SELECT as_of, revision_text FROM %s{revTable} WHERE %s{entityTable}_id = @id ORDER BY as_of DESC"
[ idParam key ]
Map.toRevision
conn
/// Find all revisions for all posts for the given web log
let findByWebLog<'TKey> revTable entityTable (keyFunc: string -> 'TKey) webLogId conn =
Custom.list
let findByWebLog<'TKey> revTable entityTable (keyFunc: string -> 'TKey) webLogId (conn: SqliteConnection) =
conn.customList
$"SELECT pr.*
FROM %s{revTable} pr
INNER JOIN %s{entityTable} p ON p.data ->> 'Id' = pr.{entityTable}_id
WHERE p.{Document.Query.whereByWebLog}
INNER JOIN %s{entityTable} p ON p.data->>'Id' = pr.{entityTable}_id
WHERE p.{Query.whereByFields Any [ webLogField webLogId ]}
ORDER BY as_of DESC"
[ webLogParam webLogId ]
(fun rdr -> keyFunc (Map.getString $"{entityTable}_id" rdr), Map.toRevision rdr)
conn
/// Update a page or post's revisions
let update<'TKey> revTable entityTable (key: 'TKey) oldRevs newRevs conn = backgroundTask {
let update<'TKey> revTable entityTable (key: 'TKey) oldRevs newRevs (conn: SqliteConnection) = backgroundTask {
let toDelete, toAdd = Utils.diffRevisions oldRevs newRevs
for delRev in toDelete do
do! Custom.nonQuery
do! conn.customNonQuery
$"DELETE FROM %s{revTable} WHERE %s{entityTable}_id = @id AND as_of = @asOf"
[ idParam key; sqlParam "@asOf" (instantParam delRev.AsOf) ]
conn
for addRev in toAdd do
do! Custom.nonQuery
do! conn.customNonQuery
$"INSERT INTO {revTable} VALUES (@id, @asOf, @text)"
[ idParam key; sqlParam "asOf" (instantParam addRev.AsOf); sqlParam "@text" (string addRev.Text) ]
conn
}
+36 -34
View File
@@ -17,8 +17,10 @@ type SQLitePageData(conn: SqliteConnection, log: ILogger) =
/// The JSON field name for the "is in page list" flag
let pgListName = nameof Page.Empty.IsInPageList
/// The JSON field for the title of the page
let titleField = $"data ->> '{nameof Page.Empty.Title}'"
/// Query to return pages sorted by title
let sortedPages fields =
Query.byFields (Query.find Table.Page) All fields
+ Query.orderBy [ Field.Named $"i:{nameof Page.Empty.Title}" ] SQLite
// SUPPORT FUNCTIONS
@@ -50,36 +52,38 @@ type SQLitePageData(conn: SqliteConnection, log: ILogger) =
/// Get all pages for a web log (without text, metadata, revisions, or prior permalinks)
let all webLogId =
log.LogTrace "Page.all"
let field = [ webLogField webLogId ]
conn.customList
$"{Query.selectFromTable Table.Page} WHERE {Document.Query.whereByWebLog} ORDER BY LOWER({titleField})"
[ webLogParam webLogId ]
(sortedPages field)
(addFieldParams field [])
(fun rdr -> { fromData<Page> rdr with Text = ""; Metadata = []; PriorPermalinks = [] })
/// Count all pages for the given web log
let countAll webLogId =
let countAll webLogId = backgroundTask {
log.LogTrace "Page.countAll"
Document.countByWebLog Table.Page webLogId conn
let! count = conn.countByFields Table.Page Any [ webLogField webLogId ]
return int count
}
/// Count all pages shown in the page list for the given web log
let countListed webLogId =
let countListed webLogId = backgroundTask {
log.LogTrace "Page.countListed"
conn.customScalar
$"""{Document.Query.countByWebLog Table.Page} AND {Query.whereByField (Field.EQ pgListName "") "true"}"""
[ webLogParam webLogId ]
(toCount >> int)
let! count = conn.countByFields Table.Page All [ webLogField webLogId; Field.Equal pgListName true ]
return int count
}
/// Find a page by its ID (without revisions and prior permalinks)
let findById pageId webLogId = backgroundTask {
let findById (pageId: PageId) webLogId = backgroundTask {
log.LogTrace "Page.findById"
match! Document.findByIdAndWebLog<PageId, Page> Table.Page pageId webLogId conn with
match! conn.findFirstByFields<Page> Table.Page All [ idField pageId; webLogField webLogId ] with
| Some page -> return Some { page with PriorPermalinks = [] }
| None -> return None
}
/// Find a complete page by its ID
let findFullById pageId webLogId = backgroundTask {
let findFullById (pageId: PageId) webLogId = backgroundTask {
log.LogTrace "Page.findFullById"
match! Document.findByIdAndWebLog<PageId, Page> Table.Page pageId webLogId conn with
match! conn.findFirstByFields<Page> Table.Page All [ idField pageId; webLogField webLogId ] with
| Some page ->
let! page = appendPageRevisions page
return Some page
@@ -93,7 +97,8 @@ type SQLitePageData(conn: SqliteConnection, log: ILogger) =
match! findById pageId webLogId with
| Some _ ->
do! conn.customNonQuery
$"DELETE FROM {Table.PageRevision} WHERE page_id = @id; {Query.Delete.byId Table.Page}"
$"{Query.delete Table.PageRevision} WHERE page_id = @id;
{Query.byId (Query.delete Table.Page) (string pageId)}"
[ idParam pageId ]
return true
| None -> return false
@@ -102,27 +107,25 @@ type SQLitePageData(conn: SqliteConnection, log: ILogger) =
/// Find a page by its permalink for the given web log
let findByPermalink (permalink: Permalink) webLogId =
log.LogTrace "Page.findByPermalink"
let linkParam = Field.EQ linkName (string permalink)
let fields = [ webLogField webLogId; Field.Equal linkName (string permalink) ]
conn.customSingle
$"""{Document.Query.selectByWebLog Table.Page} AND {Query.whereByField linkParam "@link"}"""
(addFieldParam "@link" linkParam [ webLogParam webLogId ])
pageWithoutLinks
(Query.byFields (Query.find Table.Page) All fields) (addFieldParams fields []) pageWithoutLinks
/// Find the current permalink within a set of potential prior permalinks for the given web log
let findCurrentPermalink (permalinks: Permalink list) webLogId =
log.LogTrace "Page.findCurrentPermalink"
let linkSql, linkParams = inJsonArray Table.Page (nameof Page.Empty.PriorPermalinks) "link" permalinks
conn.customSingle
$"SELECT data ->> '{linkName}' AS permalink
FROM {Table.Page}
WHERE {Document.Query.whereByWebLog} AND {linkSql}"
(webLogParam webLogId :: linkParams)
Map.toPermalink
let fields =
[ webLogField webLogId
Field.InArray (nameof Page.Empty.PriorPermalinks) Table.Page (List.map string permalinks) ]
let query =
(Query.statementWhere (Query.find Table.Page) (Query.whereByFields All fields))
.Replace("SELECT data", $"SELECT data->>'{linkName}' AS permalink")
conn.customSingle query (addFieldParams fields []) Map.toPermalink
/// Get all complete pages for the given web log
let findFullByWebLog webLogId = backgroundTask {
log.LogTrace "Page.findFullByWebLog"
let! pages = Document.findByWebLog<Page> Table.Page webLogId conn
let! pages = conn.findByFields<Page> Table.Page Any [ webLogField webLogId ]
let! withRevs = pages |> List.map appendPageRevisions |> Task.WhenAll
return List.ofArray withRevs
}
@@ -130,18 +133,17 @@ type SQLitePageData(conn: SqliteConnection, log: ILogger) =
/// Get all listed pages for the given web log (without revisions or text)
let findListed webLogId =
log.LogTrace "Page.findListed"
let fields = [ webLogField webLogId; Field.Equal pgListName true ]
conn.customList
$"""{Document.Query.selectByWebLog Table.Page} AND {Query.whereByField (Field.EQ pgListName "") "true"}
ORDER BY LOWER({titleField})"""
[ webLogParam webLogId ]
(fun rdr -> { fromData<Page> rdr with Text = "" })
(sortedPages fields) (addFieldParams fields []) (fun rdr -> { fromData<Page> rdr with Text = "" })
/// Get a page of pages for the given web log (without revisions)
let findPageOfPages webLogId pageNbr =
log.LogTrace "Page.findPageOfPages"
let field = [ webLogField webLogId ]
conn.customList
$"{Document.Query.selectByWebLog Table.Page} ORDER BY LOWER({titleField}) LIMIT @pageSize OFFSET @toSkip"
[ webLogParam webLogId; SqliteParameter("@pageSize", 26); SqliteParameter("@toSkip", (pageNbr - 1) * 25) ]
$"{sortedPages field} LIMIT @pageSize OFFSET @toSkip"
(addFieldParams field [ sqlParam "@pageSize" 26; sqlParam "@toSkip" ((pageNbr - 1) * 25) ])
(fun rdr -> { pageWithoutLinks rdr with Metadata = [] })
/// Update a page
@@ -0,0 +1,71 @@
namespace MyWebLog.Data.SQLite
open BitBadger.Documents
open BitBadger.Documents.Sqlite
open Microsoft.Data.Sqlite
open Microsoft.Extensions.Logging
open MyWebLog
open MyWebLog.Data
/// SQLite myWebLog person data implementation
type SQLitePersonData(conn: SqliteConnection, log: ILogger) =
/// Add a person
let add (person: Person) =
log.LogTrace "Person.add"
conn.insert Table.Person person
/// Find persons by their assignments
let findByAssignment (assignments: PersonAssignment list) =
log.LogTrace "Person.findByAssignment"
conn.findByFieldsOrdered<Person>
Table.Person All
[ Field.In (nameof Person.Empty.Id) (assignments |> List.map (fun it -> string it.PersonId)) ]
[ Field.Named (nameof Person.Empty.Name) ]
/// Find a person by their ID
let findById (personId: PersonId) webLogId =
log.LogTrace "Person.findById"
conn.findFirstByFields<Person> Table.Person All [ idField personId; webLogField webLogId ]
/// Delete a person by their ID
let delete personId webLogId = backgroundTask {
log.LogTrace "Person.delete"
match! findById personId webLogId with
| Some _ ->
// TODO: also remove this person from assignments in episodes and podcasts
do! conn.customNonQuery
$"{Query.byId (Query.delete Table.Person) (string personId)}"
[ idParam personId ]
return true
| None -> return false
}
/// Find a page by its permalink for the given web log
let findByWebLog webLogId =
log.LogTrace "Person.findByWebLog"
conn.findByFieldsOrdered<Person>
Table.Person All [ webLogField webLogId ] [ Field.Named (nameof Person.Empty.Name) ]
/// Restore persons from a backup
let restore persons = backgroundTask {
log.LogTrace "Person.restore"
for person in persons do do! add person
}
/// Update a page
let update (person: Person) = backgroundTask {
log.LogTrace "Person.update"
match! findById person.Id person.WebLogId with
| Some _ -> do! conn.updateById Table.Person person.Id person
| None -> ()
}
interface IPersonData with
member _.Add person = add person
member _.Delete personId webLogId = delete personId webLogId
member _.FindByAssignment assignments = findByAssignment assignments
member _.FindById personId webLogId = findById personId webLogId
member _.FindByWebLog webLogId = findByWebLog webLogId
member _.Restore pages = restore pages
member _.Update page = update page
+57 -52
View File
@@ -16,7 +16,7 @@ type SQLitePostData(conn: SqliteConnection, log: ILogger) =
let linkName = nameof Post.Empty.Permalink
/// The JSON field for when the post was published
let publishField = $"data ->> '{nameof Post.Empty.PublishedOn}'"
let publishName = nameof Post.Empty.PublishedOn
/// The name of the JSON field for the post's status
let statName = nameof Post.Empty.Status
@@ -31,7 +31,10 @@ type SQLitePostData(conn: SqliteConnection, log: ILogger) =
}
/// The SELECT statement to retrieve posts with a web log ID parameter
let postByWebLog = Document.Query.selectByWebLog Table.Post
let postByWebLog =
Query.statementWhere
(Query.find Table.Post)
(Query.whereByFields Any [ { Field.Equal "WebLogId" "" with ParameterName = Some "@webLogId" } ])
/// Return a post with no revisions or prior permalinks
let postWithoutLinks rdr =
@@ -43,7 +46,7 @@ type SQLitePostData(conn: SqliteConnection, log: ILogger) =
/// The SELECT statement to retrieve published posts with a web log ID parameter
let publishedPostByWebLog =
$"""{postByWebLog} AND {Query.whereByField (Field.EQ statName "") $"'{string Published}'"}"""
$"{postByWebLog} AND data->>'{statName}' = '{string Published}'"
/// Update a post's revisions
let updatePostRevisions (postId: PostId) oldRevs newRevs =
@@ -60,18 +63,16 @@ type SQLitePostData(conn: SqliteConnection, log: ILogger) =
}
/// Count posts in a status for the given web log
let countByStatus (status: PostStatus) webLogId =
let countByStatus (status: PostStatus) webLogId = backgroundTask {
log.LogTrace "Post.countByStatus"
let statParam = Field.EQ statName (string status)
conn.customScalar
$"""{Document.Query.countByWebLog Table.Post} AND {Query.whereByField statParam "@status"}"""
(addFieldParam "@status" statParam [ webLogParam webLogId ])
(toCount >> int)
let! count = conn.countByFields Table.Post All [ webLogField webLogId; Field.Equal statName (string status) ]
return int count
}
/// Find a post by its ID for the given web log (excluding revisions)
let findById postId webLogId = backgroundTask {
let findById (postId: PostId) webLogId = backgroundTask {
log.LogTrace "Post.findById"
match! Document.findByIdAndWebLog<PostId, Post> Table.Post postId webLogId conn with
match! conn.findFirstByFields<Post> Table.Post All [ idField postId; webLogField webLogId ] with
| Some post -> return Some { post with PriorPermalinks = [] }
| None -> return None
}
@@ -79,16 +80,14 @@ type SQLitePostData(conn: SqliteConnection, log: ILogger) =
/// Find a post by its permalink for the given web log (excluding revisions)
let findByPermalink (permalink: Permalink) webLogId =
log.LogTrace "Post.findByPermalink"
let linkParam = Field.EQ linkName (string permalink)
let fields = [ webLogField webLogId; Field.Equal linkName (string permalink) ]
conn.customSingle
$"""{Document.Query.selectByWebLog Table.Post} AND {Query.whereByField linkParam "@link"}"""
(addFieldParam "@link" linkParam [ webLogParam webLogId ])
postWithoutLinks
(Query.byFields (Query.find Table.Post) All fields) (addFieldParams fields []) postWithoutLinks
/// Find a complete post by its ID for the given web log
let findFullById postId webLogId = backgroundTask {
log.LogTrace "Post.findFullById"
match! Document.findByIdAndWebLog<PostId, Post> Table.Post postId webLogId conn with
match! conn.findFirstByFields<Post> Table.Post All [ idField postId; webLogField webLogId ] with
| Some post ->
let! post = appendPostRevisions post
return Some post
@@ -101,10 +100,12 @@ type SQLitePostData(conn: SqliteConnection, log: ILogger) =
match! findById postId webLogId with
| Some _ ->
do! conn.customNonQuery
$"""DELETE FROM {Table.PostRevision} WHERE post_id = @id;
DELETE FROM {Table.PostComment}
WHERE {Query.whereByField (Field.EQ (nameof Comment.Empty.PostId) "") "@id"};
{Query.Delete.byId Table.Post}"""
$"""{Query.delete Table.PostRevision} WHERE post_id = @id;
{Query.byFields
(Query.delete Table.PostComment)
Any
[ { Field.EQ (nameof Comment.Empty.PostId) postId with ParameterName = Some "@id" }]};
{Query.byId (Query.delete Table.Post) (string postId)}"""
[ idParam postId ]
return true
| None -> return false
@@ -113,18 +114,18 @@ type SQLitePostData(conn: SqliteConnection, log: ILogger) =
/// Find the current permalink from a list of potential prior permalinks for the given web log
let findCurrentPermalink (permalinks: Permalink list) webLogId =
log.LogTrace "Post.findCurrentPermalink"
let linkSql, linkParams = inJsonArray Table.Post (nameof Post.Empty.PriorPermalinks) "link" permalinks
conn.customSingle
$"SELECT data ->> '{linkName}' AS permalink
FROM {Table.Post}
WHERE {Document.Query.whereByWebLog} AND {linkSql}"
(webLogParam webLogId :: linkParams)
Map.toPermalink
let fields =
[ webLogField webLogId
Field.InArray (nameof Post.Empty.PriorPermalinks) Table.Post (List.map string permalinks) ]
let query =
(Query.statementWhere (Query.find Table.Post) (Query.whereByFields All fields))
.Replace("SELECT data", $"SELECT data->>'{linkName}' AS permalink")
conn.customSingle query (addFieldParams fields []) Map.toPermalink
/// Get all complete posts for the given web log
let findFullByWebLog webLogId = backgroundTask {
log.LogTrace "Post.findFullByWebLog"
let! posts = Document.findByWebLog<Post> Table.Post webLogId conn
let! posts = conn.findByFields<Post> Table.Post Any [ webLogField webLogId ]
let! withRevs = posts |> List.map appendPostRevisions |> Task.WhenAll
return List.ofArray withRevs
}
@@ -132,21 +133,22 @@ type SQLitePostData(conn: SqliteConnection, 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, catParams = inJsonArray Table.Post (nameof Post.Empty.CategoryIds) "catId" categoryIds
let catIdField = Field.InArray (nameof Post.Empty.CategoryIds) Table.Post (List.map string categoryIds)
conn.customList
$"{publishedPostByWebLog} AND {catSql}
ORDER BY {publishField} DESC
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
(webLogParam webLogId :: catParams)
$"""{publishedPostByWebLog} AND {Query.whereByFields Any [ catIdField ]}
{Query.orderBy [ Field.Named $"{publishName} DESC" ] SQLite}
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"""
(addFieldParams [ webLogField webLogId; catIdField ] [])
postWithoutLinks
/// Get a page of posts for the given web log (excludes text and revisions)
let findPageOfPosts webLogId pageNbr postsPerPage =
log.LogTrace "Post.findPageOfPosts"
let order =
Query.orderBy
[ Field.Named $"{publishName} DESC NULLS FIRST"; Field.Named (nameof Post.Empty.UpdatedOn) ] SQLite
conn.customList
$"{postByWebLog}
ORDER BY {publishField} DESC NULLS FIRST, data ->> '{nameof Post.Empty.UpdatedOn}'
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
$"{postByWebLog}{order} LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
[ webLogParam webLogId ]
postWithoutText
@@ -154,36 +156,39 @@ type SQLitePostData(conn: SqliteConnection, log: ILogger) =
let findPageOfPublishedPosts webLogId pageNbr postsPerPage =
log.LogTrace "Post.findPageOfPublishedPosts"
conn.customList
$"{publishedPostByWebLog}
ORDER BY {publishField} DESC
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
$"""{publishedPostByWebLog}
{Query.orderBy [ Field.Named $"{publishName} DESC" ] SQLite}
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"""
[ webLogParam webLogId ]
postWithoutLinks
/// Get a page of tagged posts for the given web log (excludes revisions)
let findPageOfTaggedPosts webLogId (tag : string) pageNbr postsPerPage =
log.LogTrace "Post.findPageOfTaggedPosts"
let tagSql, tagParams = inJsonArray Table.Post (nameof Post.Empty.Tags) "tag" [ tag ]
let tagField = Field.InArray (nameof Post.Empty.Tags) Table.Post [ tag ]
conn.customList
$"{publishedPostByWebLog} AND {tagSql}
ORDER BY {publishField} DESC
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
(webLogParam webLogId :: tagParams)
$"""{publishedPostByWebLog} AND {Query.whereByFields Any [ tagField ]}
{Query.orderBy [ Field.Named $"{publishName} DESC" ] SQLite}
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"""
(addFieldParams [ webLogField webLogId; tagField ] [])
postWithoutLinks
/// Find the next newest and oldest post from a publish date for the given web log
let findSurroundingPosts webLogId (publishedOn : Instant) = backgroundTask {
log.LogTrace "Post.findSurroundingPosts"
let! older =
let adjacent op order =
let fields = [
webLogField webLogId
Field.Equal (nameof Post.Empty.Status) (string Published)
(if op = "<" then Field.Less else Field.Greater) publishName (instantParam publishedOn)
]
conn.customSingle
$"{publishedPostByWebLog} AND {publishField} < @publishedOn ORDER BY {publishField} DESC LIMIT 1"
[ webLogParam webLogId; SqliteParameter("@publishedOn", instantParam publishedOn) ]
postWithoutLinks
let! newer =
conn.customSingle
$"{publishedPostByWebLog} AND {publishField} > @publishedOn ORDER BY {publishField} LIMIT 1"
[ webLogParam webLogId; SqliteParameter("@publishedOn", instantParam publishedOn) ]
(Query.byFields (Query.find Table.Post) All fields
+ Query.orderBy [ Field.Named (publishName + order) ] SQLite + " LIMIT 1")
(addFieldParams fields [])
postWithoutLinks
let! older = adjacent "<" " DESC"
let! newer = adjacent ">" ""
return older, newer
}
+6 -13
View File
@@ -11,9 +11,9 @@ open MyWebLog.Data
type SQLiteTagMapData(conn: SqliteConnection, log: ILogger) =
/// Find a tag mapping by its ID for the given web log
let findById tagMapId webLogId =
let findById (tagMapId: TagMapId) webLogId =
log.LogTrace "TagMap.findById"
Document.findByIdAndWebLog<TagMapId, TagMap> Table.TagMap tagMapId webLogId conn
conn.findFirstByFields<TagMap> Table.TagMap All [ idField tagMapId; webLogField webLogId ]
/// Delete a tag mapping for the given web log
let delete tagMapId webLogId = backgroundTask {
@@ -28,25 +28,18 @@ type SQLiteTagMapData(conn: SqliteConnection, log: ILogger) =
/// Find a tag mapping by its URL value for the given web log
let findByUrlValue (urlValue: string) webLogId =
log.LogTrace "TagMap.findByUrlValue"
let urlParam = Field.EQ (nameof TagMap.Empty.UrlValue) urlValue
conn.customSingle
$"""{Document.Query.selectByWebLog Table.TagMap} AND {Query.whereByField urlParam "@urlValue"}"""
(addFieldParam "@urlValue" urlParam [ webLogParam webLogId ])
fromData<TagMap>
conn.findFirstByFields<TagMap>
Table.TagMap All [ webLogField webLogId; Field.Equal (nameof TagMap.Empty.UrlValue) urlValue ]
/// Get all tag mappings for the given web log
let findByWebLog webLogId =
log.LogTrace "TagMap.findByWebLog"
Document.findByWebLog<TagMap> Table.TagMap webLogId conn
conn.findByFields<TagMap> Table.TagMap Any [ webLogField webLogId ]
/// Find any tag mappings in a list of tags for the given web log
let findMappingForTags (tags: string list) webLogId =
log.LogTrace "TagMap.findMappingForTags"
let mapSql, mapParams = inClause $"AND data ->> '{nameof TagMap.Empty.Tag}'" "tag" id tags
conn.customList
$"{Document.Query.selectByWebLog Table.TagMap} {mapSql}"
(webLogParam webLogId :: mapParams)
fromData<TagMap>
conn.findByFields<TagMap> Table.TagMap All [ webLogField webLogId; Field.In (nameof TagMap.Empty.Tag) tags ]
/// Save a tag mapping
let save (tagMap: TagMap) =
+9 -7
View File
@@ -10,8 +10,8 @@ open MyWebLog.Data
/// SQLite myWebLog theme data implementation
type SQLiteThemeData(conn : SqliteConnection, log: ILogger) =
/// The JSON field for the theme ID
let idField = $"data ->> '{nameof Theme.Empty.Id}'"
/// The name of the theme ID field
let idName = nameof Theme.Empty.Id
/// Convert a document to a theme with no template text
let withoutTemplateText (rdr: SqliteDataReader) =
@@ -25,9 +25,10 @@ type SQLiteThemeData(conn : SqliteConnection, log: ILogger) =
/// Retrieve all themes (except 'admin'; excludes template text)
let all () =
log.LogTrace "Theme.all"
let fields = [ Field.NE idName "admin" ]
conn.customList
$"{Query.selectFromTable Table.Theme} WHERE {idField} <> 'admin' ORDER BY {idField}"
[]
(Query.byFields (Query.find Table.Theme) Any fields + Query.orderBy [ Field.Named idName ] SQLite)
(addFieldParams fields [])
withoutTemplateText
/// Does a given theme exist?
@@ -43,7 +44,7 @@ type SQLiteThemeData(conn : SqliteConnection, log: ILogger) =
/// Find a theme by its ID (excludes the text of templates)
let findByIdWithoutText (themeId: ThemeId) =
log.LogTrace "Theme.findByIdWithoutText"
conn.customSingle (Query.Find.byId Table.Theme) [ idParam themeId ] withoutTemplateText
conn.customSingle (Query.byId (Query.find Table.Theme) (string themeId)) [ idParam themeId ] withoutTemplateText
/// Delete a theme by its ID
let delete themeId = backgroundTask {
@@ -51,7 +52,8 @@ type SQLiteThemeData(conn : SqliteConnection, log: ILogger) =
match! findByIdWithoutText themeId with
| Some _ ->
do! conn.customNonQuery
$"DELETE FROM {Table.ThemeAsset} WHERE theme_id = @id; {Query.Delete.byId Table.Theme}"
$"{Query.delete Table.ThemeAsset} WHERE theme_id = @id;
{Query.byId (Query.delete Table.Theme) (string themeId)}"
[ idParam themeId ]
return true
| None -> return false
@@ -89,7 +91,7 @@ type SQLiteThemeAssetData(conn : SqliteConnection, log: ILogger) =
/// Delete all assets for the given theme
let deleteByTheme (themeId: ThemeId) =
log.LogTrace "ThemeAsset.deleteByTheme"
conn.customNonQuery $"DELETE FROM {Table.ThemeAsset} WHERE theme_id = @id" [ idParam themeId ]
conn.customNonQuery $"{Query.delete Table.ThemeAsset} WHERE theme_id = @id" [ idParam themeId ]
/// Find a theme asset by its ID
let findById assetId =
+22 -21
View File
@@ -9,45 +9,46 @@ open MyWebLog.Data
/// SQLite myWebLog web log data implementation
type SQLiteWebLogData(conn: SqliteConnection, log: ILogger) =
/// Add a web log
let add webLog =
log.LogTrace "WebLog.add"
conn.insert<WebLog> Table.WebLog webLog
/// Retrieve all web logs
let all () =
log.LogTrace "WebLog.all"
conn.findAll<WebLog> Table.WebLog
/// Delete a web log by its ID
let delete webLogId =
log.LogTrace "WebLog.delete"
let webLogMatches = Query.whereByField (Field.EQ "WebLogId" "") "@webLogId"
let subQuery table = $"(SELECT data ->> 'Id' FROM {table} WHERE {webLogMatches})"
let webLogMatches =
Query.whereByFields Any [ { Field.Equal "WebLogId" "" with ParameterName = Some "@webLogId" } ]
let subQuery table = $"(SELECT data->>'Id' FROM {table} WHERE {webLogMatches})"
Custom.nonQuery
$"""DELETE FROM {Table.PostComment} WHERE data ->> 'PostId' IN {subQuery Table.Post};
DELETE FROM {Table.PostRevision} WHERE post_id IN {subQuery Table.Post};
DELETE FROM {Table.PageRevision} WHERE page_id IN {subQuery Table.Page};
DELETE FROM {Table.Post} WHERE {webLogMatches};
DELETE FROM {Table.Page} WHERE {webLogMatches};
DELETE FROM {Table.Category} WHERE {webLogMatches};
DELETE FROM {Table.TagMap} WHERE {webLogMatches};
DELETE FROM {Table.Upload} WHERE web_log_id = @webLogId;
DELETE FROM {Table.WebLogUser} WHERE {webLogMatches};
DELETE FROM {Table.WebLog} WHERE {Query.whereById "@webLogId"}"""
$"""{Query.delete Table.PostComment} WHERE data ->> 'PostId' IN {subQuery Table.Post};
{Query.delete Table.PostRevision} WHERE post_id IN {subQuery Table.Post};
{Query.delete Table.PageRevision} WHERE page_id IN {subQuery Table.Page};
{Query.delete Table.Post} WHERE {webLogMatches};
{Query.delete Table.Page} WHERE {webLogMatches};
{Query.delete Table.Category} WHERE {webLogMatches};
{Query.delete Table.TagMap} WHERE {webLogMatches};
{Query.delete Table.WebLogUser} WHERE {webLogMatches};
{Query.delete Table.Upload} WHERE web_log_id = @webLogId;
{Query.delete Table.WebLog} WHERE data->>'Id' = @webLogId"""
[ webLogParam webLogId ]
/// Find a web log by its host (URL base)
let findByHost (url: string) =
log.LogTrace "WebLog.findByHost"
conn.findFirstByField<WebLog> Table.WebLog (Field.EQ (nameof WebLog.Empty.UrlBase) url)
conn.findFirstByFields<WebLog> Table.WebLog Any [ Field.Equal (nameof WebLog.Empty.UrlBase) url ]
/// Find a web log by its ID
let findById webLogId =
log.LogTrace "WebLog.findById"
conn.findById<WebLogId, WebLog> Table.WebLog webLogId
/// Update redirect rules for a web log
let updateRedirectRules (webLog: WebLog) =
log.LogTrace "WebLog.updateRedirectRules"
@@ -57,12 +58,12 @@ type SQLiteWebLogData(conn: SqliteConnection, log: ILogger) =
let updateRssOptions (webLog: WebLog) =
log.LogTrace "WebLog.updateRssOptions"
conn.patchById Table.WebLog webLog.Id {| Rss = webLog.Rss |}
/// Update settings for a web log
let updateSettings (webLog: WebLog) =
log.LogTrace "WebLog.updateSettings"
conn.updateById Table.WebLog webLog.Id webLog
interface IWebLogData with
member _.Add webLog = add webLog
member _.All () = all ()
@@ -16,17 +16,18 @@ type SQLiteWebLogUserData(conn: SqliteConnection, log: ILogger) =
conn.insert<WebLogUser> Table.WebLogUser user
/// Find a user by their ID for the given web log
let findById userId webLogId =
let findById (userId: WebLogUserId) webLogId =
log.LogTrace "WebLogUser.findById"
Document.findByIdAndWebLog<WebLogUserId, WebLogUser> Table.WebLogUser userId webLogId conn
conn.findFirstByFields<WebLogUser> Table.WebLogUser All [ idField userId; webLogField webLogId ]
/// Delete a user if they have no posts or pages
let delete userId webLogId = backgroundTask {
log.LogTrace "WebLogUser.delete"
match! findById userId webLogId with
| Some _ ->
let! pageCount = conn.countByField Table.Page (Field.EQ (nameof Page.Empty.AuthorId) (string userId))
let! postCount = conn.countByField Table.Post (Field.EQ (nameof Post.Empty.AuthorId) (string userId))
let author = [ Field.Equal (nameof Page.Empty.AuthorId) (string userId) ]
let! pageCount = conn.countByFields Table.Page Any author
let! postCount = conn.countByFields Table.Post Any author
if pageCount + postCount > 0 then
return Error "User has pages or posts; cannot delete"
else
@@ -38,27 +39,24 @@ type SQLiteWebLogUserData(conn: SqliteConnection, log: ILogger) =
/// Find a user by their e-mail address for the given web log
let findByEmail (email: string) webLogId =
log.LogTrace "WebLogUser.findByEmail"
let emailParam = Field.EQ (nameof WebLogUser.Empty.Email) email
conn.customSingle
$"""{Document.Query.selectByWebLog Table.WebLogUser}
AND {Query.whereByField emailParam "@email"}"""
(addFieldParam "@email" emailParam [ webLogParam webLogId ])
fromData<WebLogUser>
conn.findFirstByFields
Table.WebLogUser All [ webLogField webLogId; Field.Equal (nameof WebLogUser.Empty.Email) email ]
/// Get all users for the given web log
let findByWebLog webLogId = backgroundTask {
log.LogTrace "WebLogUser.findByWebLog"
let! users = Document.findByWebLog<WebLogUser> Table.WebLogUser webLogId conn
let! users = conn.findByFields<WebLogUser> Table.WebLogUser Any [ webLogField webLogId ]
return users |> List.sortBy _.PreferredName.ToLowerInvariant()
}
/// Find the names of users by their IDs for the given web log
let findNames webLogId (userIds: WebLogUserId list) =
log.LogTrace "WebLogUser.findNames"
let nameSql, nameParams = inClause $"AND data ->> '{nameof WebLogUser.Empty.Id}'" "id" string userIds
let fields = [ webLogField webLogId; Field.In (nameof WebLogUser.Empty.Id) (List.map string userIds) ]
let query = Query.statementWhere (Query.find Table.WebLogUser) (Query.whereByFields All fields)
conn.customList
$"{Document.Query.selectByWebLog Table.WebLogUser} {nameSql}"
(webLogParam webLogId :: nameParams)
query
(addFieldParams fields [])
(fun rdr ->
let user = fromData<WebLogUser> rdr
{ Name = string user.Id; Value = user.DisplayName })
+132 -100
View File
@@ -1,7 +1,6 @@
namespace MyWebLog.Data
open System
open System.Threading.Tasks
open BitBadger.Documents
open BitBadger.Documents.Sqlite
open Microsoft.Data.Sqlite
@@ -13,115 +12,130 @@ open NodaTime
/// SQLite myWebLog data implementation
type SQLiteData(conn: SqliteConnection, log: ILogger<SQLiteData>, ser: JsonSerializer) =
/// Create tables (and their associated indexes) if they do not exist
let ensureTables () = backgroundTask {
Configuration.useSerializer (Utils.createDocumentSerializer ser)
let! tables = conn.customList "SELECT name FROM sqlite_master WHERE type = 'table'" [] _.GetString(0)
let needsTable table =
not (List.contains table tables)
let jsonTable table =
$"{Query.Definition.ensureTable table}; {Query.Definition.ensureKey table}"
let tasks =
seq {
// Theme tables
if needsTable Table.Theme then jsonTable Table.Theme
if needsTable Table.ThemeAsset then
let creatingTable = "Creating {Table} table..."
// Theme tables
if needsTable Table.Theme then
log.LogInformation(creatingTable, Table.Theme)
do! conn.ensureTable Table.Theme
if needsTable Table.ThemeAsset then
log.LogInformation(creatingTable, Table.ThemeAsset)
do! conn.customNonQuery
$"CREATE TABLE {Table.ThemeAsset} (
theme_id TEXT NOT NULL,
path TEXT NOT NULL,
updated_on TEXT NOT NULL,
data BLOB NOT NULL,
PRIMARY KEY (theme_id, path))"
// Web log table
if needsTable Table.WebLog then jsonTable Table.WebLog
// Category table
if needsTable Table.Category then
$"""{jsonTable Table.Category};
{Query.Definition.ensureIndexOn Table.Category "web_log" [ nameof Category.Empty.WebLogId ]}"""
// Web log user table
if needsTable Table.WebLogUser then
$"""{jsonTable Table.WebLogUser};
{Query.Definition.ensureIndexOn
Table.WebLogUser
"email"
[ nameof WebLogUser.Empty.WebLogId; nameof WebLogUser.Empty.Email ]}"""
// Page tables
if needsTable Table.Page then
$"""{jsonTable Table.Page};
{Query.Definition.ensureIndexOn Table.Page "author" [ nameof Page.Empty.AuthorId ]};
{Query.Definition.ensureIndexOn
Table.Page "permalink" [ nameof Page.Empty.WebLogId; nameof Page.Empty.Permalink ]}"""
if needsTable Table.PageRevision then
PRIMARY KEY (theme_id, path))" []
// Web log table
if needsTable Table.WebLog then
log.LogInformation(creatingTable, Table.WebLog)
do! conn.ensureTable Table.WebLog
// Category table
if needsTable Table.Category then
log.LogInformation(creatingTable, Table.Category)
do! conn.ensureTable Table.Category
do! conn.ensureFieldIndex Table.Category "web_log" [ nameof Category.Empty.WebLogId ]
// Web log user table
if needsTable Table.WebLogUser then
log.LogInformation(creatingTable, Table.WebLogUser)
do! conn.ensureTable Table.WebLogUser
do! conn.ensureFieldIndex
Table.WebLogUser "email" [ nameof WebLogUser.Empty.WebLogId; nameof WebLogUser.Empty.Email ]
// Page tables
if needsTable Table.Page then
log.LogInformation(creatingTable, Table.Page)
do! conn.ensureTable Table.Page
do! conn.ensureFieldIndex Table.Page "author" [ nameof Page.Empty.AuthorId ]
do! conn.ensureFieldIndex Table.Page "permalink" [ nameof Page.Empty.WebLogId; nameof Page.Empty.Permalink ]
if needsTable Table.PageRevision then
log.LogInformation(creatingTable, Table.PageRevision)
do! conn.customNonQuery
$"CREATE TABLE {Table.PageRevision} (
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 Table.Post then
$"""{jsonTable Table.Post};
{Query.Definition.ensureIndexOn Table.Post "author" [ nameof Post.Empty.AuthorId ]};
{Query.Definition.ensureIndexOn
Table.Post "permalink" [ nameof Post.Empty.WebLogId; nameof Post.Empty.Permalink ]};
{Query.Definition.ensureIndexOn
Table.Post
"status"
[ nameof Post.Empty.WebLogId; nameof Post.Empty.Status; nameof Post.Empty.UpdatedOn ]}"""
// TODO: index categories by post?
if needsTable Table.PostRevision then
PRIMARY KEY (page_id, as_of))" []
// Person table
if needsTable Table.Person then
log.LogInformation(creatingTable, Table.Person)
do! conn.ensureTable Table.Person
do! conn.ensureFieldIndex Table.Person "web_log" [ nameof Person.Empty.WebLogId ]
// Post tables
if needsTable Table.Post then
log.LogInformation(creatingTable, Table.Post)
do! conn.ensureTable Table.Post
do! conn.ensureFieldIndex Table.Post "author" [ nameof Post.Empty.AuthorId ]
do! conn.ensureFieldIndex Table.Post "permalink" [ nameof Post.Empty.WebLogId; nameof Post.Empty.Permalink ]
do! conn.ensureFieldIndex
Table.Post
"status"
[ nameof Post.Empty.WebLogId; nameof Post.Empty.Status; nameof Post.Empty.UpdatedOn ]
// TODO: index categories by post?
if needsTable Table.PostRevision then
log.LogInformation(creatingTable, Table.PostRevision)
do! conn.customNonQuery
$"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 Table.PostComment then
$"""{jsonTable Table.PostComment};
{Query.Definition.ensureIndexOn Table.PostComment "post" [ nameof Comment.Empty.PostId ]}"""
// Tag map table
if needsTable Table.TagMap then
$"""{jsonTable Table.TagMap};
{Query.Definition.ensureIndexOn
Table.TagMap "url" [ nameof TagMap.Empty.WebLogId; nameof TagMap.Empty.UrlValue ]}"""
// Uploaded file table
if needsTable Table.Upload then
PRIMARY KEY (post_id, as_of))" []
if needsTable Table.PostComment then
log.LogInformation(creatingTable, Table.PostComment)
do! conn.ensureTable Table.PostComment
do! conn.ensureFieldIndex Table.PostComment "post" [ nameof Comment.Empty.PostId ]
// Tag map table
if needsTable Table.TagMap then
log.LogInformation(creatingTable, Table.TagMap)
do! conn.ensureTable Table.TagMap
do! conn.ensureFieldIndex Table.TagMap "url" [ nameof TagMap.Empty.WebLogId; nameof TagMap.Empty.UrlValue ]
// Uploaded file table
if needsTable Table.Upload then
log.LogInformation(creatingTable, Table.Upload)
do! conn.customNonQuery
$"CREATE TABLE {Table.Upload} (
id TEXT PRIMARY KEY,
web_log_id TEXT NOT NULL,
path TEXT NOT NULL,
updated_on TEXT NOT NULL,
data BLOB NOT NULL);
CREATE INDEX idx_{Table.Upload}_path ON {Table.Upload} (web_log_id, path)"
// Database version table
if needsTable Table.DbVersion then
CREATE INDEX idx_{Table.Upload}_path ON {Table.Upload} (web_log_id, path)" []
// Database version table
if needsTable Table.DbVersion then
log.LogInformation(creatingTable, Table.DbVersion)
do! conn.customNonQuery
$"CREATE TABLE {Table.DbVersion} (id TEXT PRIMARY KEY);
INSERT INTO {Table.DbVersion} VALUES ('{Utils.Migration.currentDbVersion}')"
}
|> Seq.map (fun sql ->
log.LogInformation $"""Creating {(sql.Replace("IF NOT EXISTS ", "").Split ' ')[2]} table..."""
conn.customNonQuery sql [])
let! _ = Task.WhenAll tasks
()
INSERT INTO {Table.DbVersion} VALUES ('{Utils.Migration.currentDbVersion}')" []
}
/// Set the database version to the specified version
let setDbVersion version =
conn.customNonQuery $"DELETE FROM {Table.DbVersion}; INSERT INTO {Table.DbVersion} VALUES ('%s{version}')" []
/// Implement the changes between v2-rc1 and v2-rc2
let migrateV2Rc1ToV2Rc2 () = backgroundTask {
let logStep = Utils.Migration.logStep log "v2-rc1 to v2-rc2"
@@ -176,7 +190,7 @@ type SQLiteData(conn: SqliteConnection, log: ILogger<SQLiteData>, ser: JsonSeria
Summary = Map.getString "summary" podcastRdr
DisplayedAuthor = Map.getString "displayed_author" podcastRdr
Email = Map.getString "email" podcastRdr
ImageUrl = Map.getString "image_url" podcastRdr |> Permalink
ImageUrl = Map.getString "image_url" podcastRdr
AppleCategory = Map.getString "apple_category" podcastRdr
AppleSubcategory = Map.tryString "apple_subcategory" podcastRdr
Explicit = Map.getString "explicit" podcastRdr |> ExplicitRating.Parse
@@ -186,7 +200,8 @@ type SQLiteData(conn: SqliteConnection, log: ILogger<SQLiteData>, ser: JsonSeria
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
People = None } // TODO: retrieve person / role assignments
} |> List.ofSeq
podcastRdr.Close()
podcasts
@@ -215,7 +230,7 @@ type SQLiteData(conn: SqliteConnection, log: ILogger<SQLiteData>, ser: JsonSeria
|> Option.map (Utils.deserialize<Chapter list> ser)
ChapterFile = Map.tryString "chapter_file" epRdr
ChapterType = Map.tryString "chapter_type" epRdr
ChapterWaypoints = None
ChapterWaypoints = None
TranscriptUrl = Map.tryString "transcript_url" epRdr
TranscriptType = Map.tryString "transcript_type" epRdr
TranscriptLang = Map.tryString "transcript_lang" epRdr
@@ -223,7 +238,8 @@ type SQLiteData(conn: SqliteConnection, log: ILogger<SQLiteData>, ser: JsonSeria
SeasonNumber = Map.tryInt "season_number" epRdr
SeasonDescription = Map.tryString "season_description" epRdr
EpisodeNumber = Map.tryString "episode_number" epRdr |> Option.map Double.Parse
EpisodeDescription = Map.tryString "episode_description" epRdr }
EpisodeDescription = Map.tryString "episode_description" epRdr
People = None } // TODO: get person / role assignments
} |> List.ofSeq
epRdr.Close()
episodes
@@ -233,7 +249,7 @@ type SQLiteData(conn: SqliteConnection, log: ILogger<SQLiteData>, ser: JsonSeria
cmd.Parameters.AddWithValue("@id", string postId) ] |> ignore
let _ = cmd.ExecuteNonQuery()
cmd.Parameters.Clear())
logStep "Migrating dates/times"
let inst (dt: DateTime) =
DateTime(dt.Ticks, DateTimeKind.Utc)
@@ -400,10 +416,10 @@ type SQLiteData(conn: SqliteConnection, log: ILogger<SQLiteData>, ser: JsonSeria
let _ = cmd.ExecuteNonQuery()
())
cmd.Parameters.Clear()
conn.Close()
conn.Open()
logStep "Dropping old tables and columns"
cmd.CommandText <-
"ALTER TABLE web_log_user DROP COLUMN salt;
@@ -412,11 +428,11 @@ type SQLiteData(conn: SqliteConnection, log: ILogger<SQLiteData>, ser: JsonSeria
DROP TABLE page_meta;
DROP TABLE web_log_feed_podcast"
do! write cmd
logStep "Setting database version to v2-rc2"
do! setDbVersion "v2-rc2"
}
/// Migrate from v2-rc2 to v2
let migrateV2Rc2ToV2 () = backgroundTask {
Utils.Migration.logStep log "v2-rc2 to v2" "Setting database version; no migration required"
@@ -435,7 +451,7 @@ type SQLiteData(conn: SqliteConnection, log: ILogger<SQLiteData>, ser: JsonSeria
Utils.Migration.logStep log "v2.1 to v2.1.1" "Setting database version; no migration required"
do! setDbVersion "v2.1.1"
}
/// Migrate from v2.1.1 to v2.2
let migrateV2point1point1ToV2point2 () = backgroundTask {
Utils.Migration.logStep log "v2.1.1 to v2.2" "Setting e-mail to lowercase"
@@ -444,6 +460,17 @@ type SQLiteData(conn: SqliteConnection, log: ILogger<SQLiteData>, ser: JsonSeria
do! setDbVersion "v2.2"
}
/// Migrate from v2.2 to v3
let migrateV2point2ToV3 () = backgroundTask {
Utils.Migration.logStep log "v2.2 to v3" "Adding auto-OpenGraph flag to all web logs"
do! Patch.byFields Table.WebLog Any [ Field.Exists (nameof WebLog.Empty.Id) ] {| AutoOpenGraph = true |}
Utils.Migration.logStep log "v2.2 to v3" "Converting auto-htmx flag to choices"
do! Patch.byFields Table.WebLog Any [ Field.Equal (nameof WebLog.Empty.AutoHtmx) true ] {| AutoHtmx = "Yes" |}
do! Patch.byFields Table.WebLog Any [ Field.Equal (nameof WebLog.Empty.AutoHtmx) false ] {| AutoHtmx = "No" |}
Utils.Migration.logStep log "v2.2 to v3" "Setting database version to v3"
do! setDbVersion "v3"
}
/// Migrate data among versions (up only)
let migrate version = backgroundTask {
let mutable v = defaultArg version ""
@@ -451,35 +478,40 @@ type SQLiteData(conn: SqliteConnection, log: ILogger<SQLiteData>, ser: JsonSeria
if v = "v2-rc1" then
do! migrateV2Rc1ToV2Rc2 ()
v <- "v2-rc2"
if v = "v2-rc2" then
do! migrateV2Rc2ToV2 ()
v <- "v2"
if v = "v2" then
do! migrateV2ToV2point1 ()
v <- "v2.1"
if v = "v2.1" then
do! migrateV2point1ToV2point1point1 ()
v <- "v2.1.1"
if v = "v2.1.1" then
do! migrateV2point1point1ToV2point2 ()
v <- "v2.2"
if v = "v2.2" then
do! migrateV2point2ToV3 ()
v <- "v3"
if v <> Utils.Migration.currentDbVersion then
log.LogWarning $"Unknown database version; assuming {Utils.Migration.currentDbVersion}"
do! setDbVersion Utils.Migration.currentDbVersion
}
/// The connection for this instance
member _.Conn = conn
interface IData with
member _.Category = SQLiteCategoryData (conn, ser, log)
member _.Page = SQLitePageData (conn, log)
member _.Person = SQLitePersonData (conn, log)
member _.Post = SQLitePostData (conn, log)
member _.TagMap = SQLiteTagMapData (conn, log)
member _.Theme = SQLiteThemeData (conn, log)
@@ -487,9 +519,9 @@ type SQLiteData(conn: SqliteConnection, log: ILogger<SQLiteData>, ser: JsonSeria
member _.Upload = SQLiteUploadData (conn, log)
member _.WebLog = SQLiteWebLogData (conn, log)
member _.WebLogUser = SQLiteWebLogUserData (conn, log)
member _.Serializer = ser
member _.StartUp () = backgroundTask {
do! ensureTables ()
let! version = conn.customSingle<string> $"SELECT id FROM {Table.DbVersion}" [] _.GetString(0)
+46 -15
View File
@@ -1,11 +1,16 @@
/// Utility functions for manipulating data
/// <summary>Utility functions for manipulating data</summary>
[<RequireQualifiedAccess>]
module internal MyWebLog.Data.Utils
open MyWebLog
open MyWebLog.ViewModels
/// Create a category hierarchy from the given list of categories
/// <summary>Create a category hierarchy from the given list of categories</summary>
/// <param name="cats">The categories from which the list should be generated</param>
/// <param name="parentId">The ID of the parent category for this list</param>
/// <param name="slugBase">The base URL to use in slugs for categories at this level</param>
/// <param name="parentNames">The names of parent categories for this level</param>
/// <returns>An array of <c>DisplayCategory</c> instances sorted alphabetically by parent category</returns>
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
@@ -19,48 +24,75 @@ let rec orderByHierarchy (cats: Category list) parentId slugBase parentNames = s
yield! orderByHierarchy cats (Some cat.Id) (Some fullSlug) ([ cat.Name ] |> List.append parentNames)
}
/// Get lists of items removed from and added to the given lists
/// <summary>Get lists of items removed from and added to the given lists</summary>
/// <typeparam name="T">The type of items in the list</typeparam>
/// <typeparam name="U">The return type of the comparision function</typeparam>
/// <param name="oldItems">The prior list</param>
/// <param name="newItems">The current list</param>
/// <param name="f">The function to use when comparing items in the list</param>
/// <returns>A tuple with <c>fst</c> being added items and <c>snd</c> being removed items</returns>
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
/// Find the revisions added and removed
/// <summary>Find the revisions added and removed</summary>
/// <param name="oldRevs">The previous revisions</param>
/// <param name="newRevs">The current revisions</param>
/// <returns>A tuple with <c>fst</c> being added revisions and <c>snd</c> being removed revisions</returns>
let diffRevisions (oldRevs: Revision list) newRevs =
diffLists oldRevs newRevs (fun rev -> $"{rev.AsOf.ToUnixTimeTicks()}|{rev.Text}")
open MyWebLog.Converters
open Newtonsoft.Json
/// Serialize an object to JSON
/// <summary>Serialize an object to JSON</summary>
/// <typeparam name="T">The type of the item being serialized</typeparam>
/// <param name="ser">The JSON serializer whose settings should be used</param>
/// <param name="item">The item to be serialized</param>
/// <returns>A string with the given object serialized to JSON</returns>
let serialize<'T> ser (item: 'T) =
JsonConvert.SerializeObject(item, Json.settings ser)
/// Deserialize a JSON string
/// <summary>Deserialize a JSON string</summary>
/// <typeparam name="T">The type of the item being deserialized</typeparam>
/// <param name="ser">The JSON serializer whose settings should be used</param>
/// <param name="value">The string with the JSON representation of the item</param>
/// <returns>The item deserialized from JSON</returns>
let deserialize<'T> (ser: JsonSerializer) value =
JsonConvert.DeserializeObject<'T>(value, Json.settings ser)
open BitBadger.Documents
/// Create a document serializer using the given JsonSerializer
/// <summary>Create a document serializer using the given JsonSerializer</summary>
/// <param name="ser">The JSON.NET serializer on which the document serializer should be based</param>
/// <returns>A document serializer instance</returns>
let createDocumentSerializer ser =
{ new IDocumentSerializer with
member _.Serialize<'T>(it: 'T) : string = serialize ser it
member _.Deserialize<'T>(it: string) : 'T = deserialize ser it
}
/// Data migration utilities
/// <summary>Data migration utilities</summary>
module Migration =
open Microsoft.Extensions.Logging
/// The current database version
let currentDbVersion = "v2.2"
/// <summary>The current database version</summary>
let currentDbVersion = "v3"
/// Log a migration step
/// <summary>Log a migration step</summary>
/// <param name="log">The logger to which the message should be logged</param>
/// <param name="migration">The migration being run</param>
/// <param name="message">The log message</param>
let logStep<'T> (log: ILogger<'T>) migration message =
log.LogInformation $"Migrating %s{migration}: %s{message}"
/// Notify the user that a backup/restore
/// <summary>Notify the user that a backup/restore is required to migrate</summary>
/// <param name="log">The logger to which the message should be logged</param>
/// <param name="oldVersion">The old (current) version of the database</param>
/// <param name="newVersion">The new (application) version required</param>
/// <param name="webLogs">All web logs contained in the database</param>
let backupAndRestoreRequired log oldVersion newVersion webLogs =
logStep log $"%s{oldVersion} to %s{newVersion}" "Requires Using Action"
@@ -74,7 +106,6 @@ module Migration =
yield! webLogs |> List.map (fun (url, slug) -> $"./myWebLog backup %s{url} {oldVersion}.%s{slug}.json") ]
|> String.concat "\n"
|> log.LogWarning
log.LogCritical "myWebLog will now exit"
exit 1 |> ignore
+208 -150
View File
@@ -3,29 +3,29 @@
open MyWebLog
open NodaTime
/// A category under which a post may be identified
/// <summary>A category under which a post may be identified</summary>
[<CLIMutable; NoComparison; NoEquality>]
type Category = {
/// The ID of the category
/// <summary>The ID of the category</summary>
Id: CategoryId
/// The ID of the web log to which the category belongs
/// <summary>The ID of the web log to which the category belongs</summary>
WebLogId: WebLogId
/// The displayed name
/// <summary>The displayed name</summary>
Name: string
/// The slug (used in category URLs)
/// <summary>The slug (used in category URLs)</summary>
Slug: string
/// A longer description of the category
/// <summary>A longer description of the category</summary>
Description: string option
/// The parent ID of this category (if a subcategory)
/// <summary>The parent ID of this category (if a subcategory)</summary>
ParentId: CategoryId option
} with
/// An empty category
/// <summary>An empty category</summary>
static member Empty =
{ Id = CategoryId.Empty
WebLogId = WebLogId.Empty
@@ -35,38 +35,38 @@ type Category = {
ParentId = None }
/// A comment on a post
/// <summary>A comment on a post</summary>
[<CLIMutable; NoComparison; NoEquality>]
type Comment = {
/// The ID of the comment
/// <summary>The ID of the comment</summary>
Id: CommentId
/// The ID of the post to which this comment applies
/// <summary>The ID of the post to which this comment applies</summary>
PostId: PostId
/// The ID of the comment to which this comment is a reply
/// <summary>The ID of the comment to which this comment is a reply</summary>
InReplyToId: CommentId option
/// The name of the commentor
/// <summary>The name of the commentor</summary>
Name: string
/// The e-mail address of the commentor
/// <summary>The e-mail address of the commentor</summary>
Email: string
/// The URL of the commentor's personal website
/// <summary>The URL of the commentor's personal website</summary>
Url: string option
/// The status of the comment
/// <summary>The status of the comment</summary>
Status: CommentStatus
/// When the comment was posted
/// <summary>When the comment was posted</summary>
PostedOn: Instant
/// The text of the comment
/// <summary>The text of the comment</summary>
Text: string
} with
/// An empty comment
/// <summary>An empty comment</summary>
static member Empty =
{ Id = CommentId.Empty
PostId = PostId.Empty
@@ -79,50 +79,53 @@ type Comment = {
Text = "" }
/// A page (text not associated with a date/time)
/// <summary>A page (text not associated with a date/time)</summary>
[<CLIMutable; NoComparison; NoEquality>]
type Page = {
/// The ID of this page
/// <summary>The ID of this page</summary>
Id: PageId
/// The ID of the web log to which this page belongs
/// <summary>The ID of the web log to which this page belongs</summary>
WebLogId: WebLogId
/// The ID of the author of this page
/// <summary>The ID of the author of this page</summary>
AuthorId: WebLogUserId
/// The title of the page
/// <summary>The title of the page</summary>
Title: string
/// The link at which this page is displayed
/// <summary>The link at which this page is displayed</summary>
Permalink: Permalink
/// When this page was published
/// <summary>When this page was published</summary>
PublishedOn: Instant
/// When this page was last updated
/// <summary>When this page was last updated</summary>
UpdatedOn: Instant
/// Whether this page shows as part of the web log's navigation
/// <summary>Whether this page shows as part of the web log's navigation</summary>
IsInPageList: bool
/// The template to use when rendering this page
/// <summary>The template to use when rendering this page</summary>
Template: string option
/// The current text of the page
/// <summary>The current text of the page</summary>
Text: string
/// Metadata for this page
/// <summary>Metadata for this page</summary>
Metadata: MetaItem list
/// Permalinks at which this page may have been previously served (useful for migrated content)
/// <summary>Permalinks at which this page may have been previously served (useful for migrated content)</summary>
PriorPermalinks: Permalink list
/// Revisions of this page
/// <summary>Revisions of this page</summary>
Revisions: Revision list
/// <summary>Common OpenGraph information for this post</summary>
OpenGraph: OpenGraphProperties option
} with
/// An empty page
/// <summary>An empty page</summary>
static member Empty =
{ Id = PageId.Empty
WebLogId = WebLogId.Empty
@@ -136,62 +139,96 @@ type Page = {
Text = ""
Metadata = []
PriorPermalinks = []
Revisions = [] }
Revisions = []
OpenGraph = None }
/// A web log post
/// <summary>A person (associated with podcasting)</summary>
[<CLIMutable; NoComparison; NoEquality>]
type Person = {
/// <summary>The ID of this person</summary>
Id: PersonId
/// <summary>The ID of the web log to which this person belongs</summary>
WebLogId: WebLogId
/// <summary>The person's name</summary>
Name: string
/// <summary>A URL for more information about this person</summary>
/// <remarks>Non-absolute URLs will be treated as a permalink</remarks>
Url: string option
/// <summary>A URL with an image / avatar for this person</summary>
/// <remarks>Non-absolute URLs will be treated as a permalink</remarks>
ImageUrl: string option
} with
/// <summary>An empty person entry</summary>
static member Empty =
{ Id = PersonId.Empty
WebLogId = WebLogId.Empty
Name = ""
Url = None
ImageUrl = None }
/// <summary>A web log post</summary>
[<CLIMutable; NoComparison; NoEquality>]
type Post = {
/// The ID of this post
/// <summary>The ID of this post</summary>
Id: PostId
/// The ID of the web log to which this post belongs
/// <summary>The ID of the web log to which this post belongs</summary>
WebLogId: WebLogId
/// The ID of the author of this post
/// <summary>The ID of the author of this post</summary>
AuthorId: WebLogUserId
/// The status
/// <summary>The status</summary>
Status: PostStatus
/// The title
/// <summary>The title</summary>
Title: string
/// The link at which the post resides
/// <summary>The link at which the post resides</summary>
Permalink: Permalink
/// The instant on which the post was originally published
/// <summary>The instant on which the post was originally published</summary>
PublishedOn: Instant option
/// The instant on which the post was last updated
/// <summary>The instant on which the post was last updated</summary>
UpdatedOn: Instant
/// The template to use in displaying the post
/// <summary>The template to use in displaying the post</summary>
Template: string option
/// The text of the post in HTML (ready to display) format
/// <summary>The text of the post in HTML (ready to display) format</summary>
Text: string
/// The Ids of the categories to which this is assigned
/// <summary>The Ids of the categories to which this is assigned</summary>
CategoryIds: CategoryId list
/// The tags for the post
/// <summary>The tags for the post</summary>
Tags: string list
/// Podcast episode information for this post
/// <summary>Podcast episode information for this post</summary>
Episode: Episode option
/// Metadata for the post
/// <summary>Metadata for the post</summary>
Metadata: MetaItem list
/// Permalinks at which this post may have been previously served (useful for migrated content)
/// <summary>Permalinks at which this post may have been previously served (useful for migrated content)</summary>
PriorPermalinks: Permalink list
/// The revisions for this post
/// <summary>The revisions for this post</summary>
Revisions: Revision list
/// <summary>OpenGraph information for this post</summary>
OpenGraph: OpenGraphProperties option
} with
/// An empty post
/// <summary>An empty post</summary>
static member Empty =
{ Id = PostId.Empty
WebLogId = WebLogId.Empty
@@ -208,139 +245,145 @@ type Post = {
Episode = None
Metadata = []
PriorPermalinks = []
Revisions = [] }
Revisions = []
OpenGraph = None }
/// <summary>
/// A mapping between a tag and its URL value, used to translate restricted characters (ex. "#1" -> "number-1")
/// </summary>
[<CLIMutable; NoComparison; NoEquality>]
type TagMap = {
/// The ID of this tag mapping
/// <summary>The ID of this tag mapping</summary>
Id: TagMapId
/// The ID of the web log to which this tag mapping belongs
/// <summary>The ID of the web log to which this tag mapping belongs</summary>
WebLogId: WebLogId
/// The tag which should be mapped to a different value in links
/// <summary>The tag which should be mapped to a different value in links</summary>
Tag: string
/// The value by which the tag should be linked
/// <summary>The value by which the tag should be linked</summary>
UrlValue: string
} with
/// An empty tag mapping
/// <summary>An empty tag mapping</summary>
static member Empty =
{ Id = TagMapId.Empty; WebLogId = WebLogId.Empty; Tag = ""; UrlValue = "" }
/// A theme
/// <summary>A theme</summary>
[<CLIMutable; NoComparison; NoEquality>]
type Theme = {
/// The ID / path of the theme
/// <summary>The ID / path of the theme</summary>
Id: ThemeId
/// A long name of the theme
/// <summary>A long name of the theme</summary>
Name: string
/// The version of the theme
/// <summary>The version of the theme</summary>
Version: string
/// The templates for this theme
/// <summary>The templates for this theme</summary>
Templates: ThemeTemplate list
} with
/// An empty theme
/// <summary>An empty theme</summary>
static member Empty =
{ Id = ThemeId.Empty; Name = ""; Version = ""; Templates = [] }
/// A theme asset (a file served as part of a theme, at /themes/[theme]/[asset-path])
/// <summary>A theme asset (a file served as part of a theme, at /themes/[theme]/[asset-path])</summary>
[<CLIMutable; NoComparison; NoEquality>]
type ThemeAsset = {
/// The ID of the asset (consists of theme and path)
/// <summary>The ID of the asset (consists of theme and path)</summary>
Id: ThemeAssetId
/// The updated date (set from the file date from the ZIP archive)
/// <summary>The updated date (set from the file date from the ZIP archive)</summary>
UpdatedOn: Instant
/// The data for the asset
/// <summary>The data for the asset</summary>
Data: byte array
} with
/// An empty theme asset
/// <summary>An empty theme asset</summary>
static member Empty =
{ Id = ThemeAssetId.Empty; UpdatedOn = Noda.epoch; Data = [||] }
/// An uploaded file
/// <summary>An uploaded file</summary>
[<CLIMutable; NoComparison; NoEquality>]
type Upload = {
/// The ID of the upload
/// <summary>The ID of the upload</summary>
Id: UploadId
/// The ID of the web log to which this upload belongs
/// <summary>The ID of the web log to which this upload belongs</summary>
WebLogId: WebLogId
/// The link at which this upload is served
/// <summary>The link at which this upload is served</summary>
Path: Permalink
/// The updated date/time for this upload
/// <summary>The updated date/time for this upload</summary>
UpdatedOn: Instant
/// The data for the upload
/// <summary>The data for the upload</summary>
Data: byte array
} with
/// An empty upload
/// <summary>An empty upload</summary>
static member Empty =
{ Id = UploadId.Empty; WebLogId = WebLogId.Empty; Path = Permalink.Empty; UpdatedOn = Noda.epoch; Data = [||] }
open Newtonsoft.Json
/// A web log
/// <summary>A web log</summary>
[<CLIMutable; NoComparison; NoEquality>]
type WebLog = {
/// The ID of the web log
/// <summary>The ID of the web log</summary>
Id: WebLogId
/// The name of the web log
/// <summary>The name of the web log</summary>
Name: string
/// The slug of the web log
/// <summary>The slug of the web log</summary>
Slug: string
/// A subtitle for the web log
/// <summary>A subtitle for the web log</summary>
Subtitle: string option
/// The default page ("posts" or a page Id)
/// <summary>The default page ("posts" or a page Id)</summary>
DefaultPage: string
/// The number of posts to display on pages of posts
/// <summary>The number of posts to display on pages of posts</summary>
PostsPerPage: int
/// The ID of the theme (also the path within /themes)
/// <summary>The ID of the theme (also the path within /themes)</summary>
ThemeId: ThemeId
/// The URL base
/// <summary>The URL base</summary>
UrlBase: string
/// The time zone in which dates/times should be displayed
/// <summary>The time zone in which dates/times should be displayed</summary>
TimeZone: string
/// The RSS options for this web log
/// <summary>The RSS options for this web log</summary>
Rss: RssOptions
/// Whether to automatically load htmx
AutoHtmx: bool
/// Where uploads are placed
/// <summary>Whether to automatically load htmx</summary>
AutoHtmx: AutoHtmxType
/// <summary>Where uploads are placed</summary>
Uploads: UploadDestination
/// Redirect rules for this weblog
/// <summary>Redirect rules for this weblog</summary>
RedirectRules: RedirectRule list
/// <summary>Whether to automatically apply OpenGraph properties to all pages / posts</summary>
AutoOpenGraph: bool
} with
/// An empty web log
/// <summary>An empty web log</summary>
static member Empty =
{ Id = WebLogId.Empty
Name = ""
@@ -352,11 +395,14 @@ type WebLog = {
UrlBase = ""
TimeZone = ""
Rss = RssOptions.Empty
AutoHtmx = false
AutoHtmx = NoAutoHtmx
Uploads = Database
RedirectRules = [] }
RedirectRules = []
AutoOpenGraph = true }
/// <summary>
/// Any extra path where this web log is hosted (blank if web log is hosted at the root of the domain)
/// </summary>
[<JsonIgnore>]
member this.ExtraPath =
let pathParts = this.UrlBase.Split "://"
@@ -365,16 +411,28 @@ type WebLog = {
else
let path = pathParts[1].Split "/"
if path.Length > 1 then $"""/{path |> Array.skip 1 |> String.concat "/"}""" else ""
/// Generate an absolute URL for the given link
/// <summary>Generate an absolute URL for the given link</summary>
/// <param name="permalink">The permalink for which an absolute URL should be generated</param>
/// <returns>An absolute URL for the given link</returns>
member this.AbsoluteUrl(permalink: Permalink) =
$"{this.UrlBase}/{permalink}"
/// Generate a relative URL for the given link
/// <summary>Convert a string URL to an absolute URL for this web log if required</summary>
/// <param name="url">The URL which may be translated to an absolute one</param>
/// <returns>The given URL if it was already absolute, or a corresponding absolute URL if not</returns>
member this.UrlToAbsolute(url: string) =
if url.StartsWith "http" then url else this.AbsoluteUrl(Permalink url)
/// <summary>Generate a relative URL for the given link</summary>
/// <param name="permalink">The permalink for which a relative URL should be generated</param>
/// <returns>A relative URL for the given link</returns>
member this.RelativeUrl(permalink: Permalink) =
$"{this.ExtraPath}/{permalink}"
/// Convert an Instant (UTC reference) to the web log's local date/time
/// <summary>Convert an Instant (UTC reference) to the web log's local date/time</summary>
/// <param name="date">The UTC <c>Instant</c> to be converted</param>
/// <returns>The local date/time for this web log</returns>
member this.LocalTime(date: Instant) =
DateTimeZoneProviders.Tzdb.GetZoneOrNull this.TimeZone
|> Option.ofObj
@@ -382,44 +440,44 @@ type WebLog = {
|> Option.defaultValue (date.ToDateTimeUtc())
/// A user of the web log
/// <summary>A user of the web log</summary>
[<CLIMutable; NoComparison; NoEquality>]
type WebLogUser = {
/// The ID of the user
/// <summary>The ID of the user</summary>
Id: WebLogUserId
/// The ID of the web log to which this user belongs
/// <summary>The ID of the web log to which this user belongs</summary>
WebLogId: WebLogId
/// The user name (e-mail address)
/// <summary>The user name (e-mail address)</summary>
Email: string
/// The user's first name
/// <summary>The user's first name</summary>
FirstName: string
/// The user's last name
/// <summary>The user's last name</summary>
LastName: string
/// The user's preferred name
/// <summary>The user's preferred name</summary>
PreferredName: string
/// The hash of the user's password
/// <summary>The hash of the user's password</summary>
PasswordHash: string
/// The URL of the user's personal site
/// <summary>The URL of the user's personal site</summary>
Url: string option
/// The user's access level
/// <summary>The user's access level</summary>
AccessLevel: AccessLevel
/// When the user was created
/// <summary>When the user was created</summary>
CreatedOn: Instant
/// When the user last logged on
/// <summary>When the user last logged on</summary>
LastSeenOn: Instant option
} with
/// An empty web log user
/// <summary>An empty web log user</summary>
static member Empty =
{ Id = WebLogUserId.Empty
WebLogId = WebLogId.Empty
@@ -429,11 +487,11 @@ type WebLogUser = {
PreferredName = ""
PasswordHash = ""
Url = None
AccessLevel = Author
AccessLevel = ContentAuthor
CreatedOn = Noda.epoch
LastSeenOn = None }
/// Get the user's displayed name
/// <summary>Get the user's displayed name</summary>
[<JsonIgnore>]
member this.DisplayName =
(seq { (match this.PreferredName with "" -> this.FirstName | n -> n); " "; this.LastName }
+6 -5
View File
@@ -1,17 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<Compile Include="PodcastTypes.fs" />
<Compile Include="SupportTypes.fs" />
<Compile Include="DataTypes.fs" />
<Compile Include="ViewModels.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Markdig" Version="0.37.0" />
<PackageReference Include="Markdown.ColorCode" Version="2.2.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NodaTime" Version="3.1.11" />
<PackageReference Update="FSharp.Core" Version="8.0.300" />
<PackageReference Include="FSharp.Core" />
<PackageReference Include="Markdig" />
<PackageReference Include="Markdown.ColorCode" />
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="NodaTime" />
</ItemGroup>
</Project>
+646
View File
@@ -0,0 +1,646 @@
namespace MyWebLog
/// <summary>Valid values for the iTunes explicit rating</summary>
[<Struct>]
type ExplicitRating =
| Yes
| No
| Clean
/// <summary>Parse a string into an explicit rating</summary>
/// <param name="rating">The string representation of the rating</param>
/// <returns>The <c>ExplicitRating</c> parsed from the string</returns>
/// <exception cref="InvalidArgumentException">If the string is not valid</exception>
static member Parse rating =
match rating with
| "yes" -> Yes
| "no" -> No
| "clean" -> Clean
| _ -> invalidArg (nameof rating) $"{rating} is not a valid explicit rating"
/// <inheritdoc />
override this.ToString() =
match this with Yes -> "yes" | No -> "no" | Clean -> "clean"
/// <summary>A location (specified by Podcast Index)</summary>
type Location = {
/// <summary>The name of the location (free-form text)</summary>
Name: string
/// <summary>A geographic coordinate string (RFC 5870)</summary>
Geo: string
/// <summary>An OpenStreetMap query</summary>
Osm: string option
}
open NodaTime
/// <summary>A chapter in a podcast episode</summary>
type Chapter = {
/// <summary>The start time for the chapter</summary>
StartTime: Duration
/// <summary>The title for this chapter</summary>
Title: string option
/// <summary>A URL for an image for this chapter</summary>
ImageUrl: string option
/// <summary>A URL with information pertaining to this chapter</summary>
Url: string option
/// <summary>Whether this chapter is hidden</summary>
IsHidden: bool option
/// <summary>The episode end time for the chapter</summary>
EndTime: Duration option
/// <summary>A location that applies to a chapter</summary>
Location: Location option
} with
/// <summary>An empty chapter</summary>
static member Empty =
{ StartTime = Duration.Zero
Title = None
ImageUrl = None
Url = None
IsHidden = None
EndTime = None
Location = None }
/// <summary>A group to which a podcast person can belong</summary>
[<Struct>]
type PersonGroup =
| Administration
| AudioPostProduction
| AudioProduction
| Cast
| Community
| CreativeDirection
| Misc
| VideoPostProduction
| VideoProduction
| Visuals
| Writing
/// <summary>Parse a string into a group</summary>
/// <param name="group">The string representation of the group</param>
/// <returns>The <c>PersonGroup</c> instance parsed from the string</returns>
/// <exception cref="InvalidArgumentException">If the string is not valid</exception>
static member Parse group =
match group with
| "Administration" -> Administration
| "Audio Post-Production" -> AudioPostProduction
| "Audio Production" -> AudioProduction
| "Cast" -> Cast
| "Community" -> Community
| "Creative Direction" -> CreativeDirection
| "Misc." -> Misc
| "Video Post-Production" -> VideoPostProduction
| "Video Production" -> VideoProduction
| "Visuals" -> Visuals
| "Writing" -> Writing
| _ -> invalidArg (nameof group) $"{group} is not a valid group"
/// <inheritdoc />
override this.ToString() =
match this with
| Administration -> "Administration"
| AudioPostProduction -> "Audio Post-Production"
| AudioProduction -> "Audio Production"
| Cast -> "Cast"
| Community -> "Community"
| CreativeDirection -> "Creative Direction"
| Misc -> "Misc."
| VideoPostProduction -> "Video Post-Production"
| VideoProduction -> "Video Production"
| Visuals -> "Visuals"
| Writing -> "Writing"
/// <summary>The role a person plays for a podcast or a podcast episode</summary>
type PersonRole =
| Announcer
| AssistantCamera
| AssistantDirector
| AssistantEditor
| AssociateProducer
| AudioEditor
| AudioEngineer
| Author
| BookingCoordinator
| CameraGrip
| CameraOperator
| CoHost
| Composer
| Consultant
| ContentManager
| CoverArtDesigner
| CoWriter
| CreativeDirector
| DevelopmentProducer
| Director
| EditorialDirector
| ExecutiveProducer
| FactChecker
| FoleyArtist
| GraphicDesigner
| Guest
| GuestHost
| GuestWriter
| Host
| Intern
| LightingDesigner
| Logger
| ManagingEditor
| MarketingManager
| MusicContributor
| MusicProduction
| Narrator
| PostProductionEngineer
| Producer
| ProductionAssistant
| ProductionCoordinator
| RemoteRecordingEngineer
| Reporter
| Researcher
| SalesManager
| SalesRepresentative
| ScriptCoordinator
| ScriptEditor
| SeniorProducer
| SocialMediaManager
| Songwriter
| SoundDesigner
| StoryEditor
| StudioCoordinator
| TechnicalDirector
| TechnicalManager
| ThemeMusic
| Transcriber
| Translator
| VideoEditor
| VoiceActor
| Writer
| WritingEditor
/// <summary>Parse a string into a role</summary>
/// <param name="role">The string representation of the role</param>
/// <returns>The <c>PersonRole</c> instance parsed from the string</returns>
/// <exception cref="InvalidArgumentException">If the string is not valid</exception>
static member Parse role =
match role with
| "Announcer" -> Announcer
| "Assistant Camera" -> AssistantCamera
| "Assistant Director" -> AssistantDirector
| "Assistant Editor" -> AssistantEditor
| "Associate Producer" -> AssociateProducer
| "Audio Editor" -> AudioEditor
| "Audio Engineer" -> AudioEngineer
| "Author" -> Author
| "Booking Coordinator" -> BookingCoordinator
| "Camera Grip" -> CameraGrip
| "Camera Operator" -> CameraOperator
| "Co-Host" -> CoHost
| "Composer" -> Composer
| "Consultant" -> Consultant
| "Content Manager" -> ContentManager
| "Cover Art Designer" -> CoverArtDesigner
| "Co-Writer" -> CoWriter
| "Creative Director" -> CreativeDirector
| "Development Producer" -> DevelopmentProducer
| "Director" -> Director
| "Editorial Director" -> EditorialDirector
| "Executive Producer" -> ExecutiveProducer
| "Fact Checker" -> FactChecker
| "Foley Artist" -> FoleyArtist
| "Graphic Designer" -> GraphicDesigner
| "Guest" -> Guest
| "Guest Host" -> GuestHost
| "Guest Writer" -> GuestWriter
| "Host" -> Host
| "Intern" -> Intern
| "Lighting Designer" -> LightingDesigner
| "Logger" -> Logger
| "Managing Editor" -> ManagingEditor
| "Marketing Manager" -> MarketingManager
| "Music Contributor" -> MusicContributor
| "Music Production" -> MusicProduction
| "Narrator" -> Narrator
| "Post Production Engineer" -> PostProductionEngineer
| "Producer" -> Producer
| "Production Assistant" -> ProductionAssistant
| "Production Coordinator" -> ProductionCoordinator
| "Remote Recording Engineer" -> RemoteRecordingEngineer
| "Reporter" -> Reporter
| "Researcher" -> Researcher
| "Sales Manager" -> SalesManager
| "Sales Representative" -> SalesRepresentative
| "Script Coordinator" -> ScriptCoordinator
| "Script Editor" -> ScriptEditor
| "Senior Producer" -> SeniorProducer
| "Social Media Manager" -> SocialMediaManager
| "Songwriter" -> Songwriter
| "Sound Designer" -> SoundDesigner
| "Story Editor" -> StoryEditor
| "Studio Coordinator" -> StudioCoordinator
| "Technical Director" -> TechnicalDirector
| "Technical Manager" -> TechnicalManager
| "Theme Music" -> ThemeMusic
| "Transcriber" -> Transcriber
| "Translator" -> Translator
| "Editor (Video)" -> VideoEditor
| "Voice Actor" -> VoiceActor
| "Writer" -> Writer
| "Editor (Writing)" -> WritingEditor
| _ -> invalidArg (nameof role) $"{role} is not a valid role"
/// <summary>Role / group cross-reference</summary>
static member GroupXref =
[ Announcer, Cast
AssistantCamera, VideoProduction
AssistantDirector, CreativeDirection
AssistantEditor, VideoPostProduction
AssociateProducer, CreativeDirection
AudioEditor, AudioPostProduction
AudioEngineer, AudioProduction
Author, Writing
BookingCoordinator, Administration
CameraGrip, VideoProduction
CameraOperator, VideoProduction
CoHost, Cast
Composer, AudioPostProduction
ContentManager, Administration
Consultant, Misc
CoverArtDesigner, Visuals
CoWriter, Writing
CreativeDirector, CreativeDirection
DevelopmentProducer, CreativeDirection
Director, CreativeDirection
EditorialDirector, Writing
ExecutiveProducer, CreativeDirection
FactChecker, Writing
FoleyArtist, AudioPostProduction
GraphicDesigner, Visuals
Guest, Cast
GuestHost, Cast
GuestWriter, Writing
Host, Cast
Intern, Misc
LightingDesigner, VideoProduction
Logger, Writing
ManagingEditor, Writing
MarketingManager, Administration
MusicContributor, AudioPostProduction
MusicProduction, AudioPostProduction
Narrator, Cast
PostProductionEngineer, AudioProduction
Producer, CreativeDirection
ProductionAssistant, Administration
ProductionCoordinator, Administration
RemoteRecordingEngineer, AudioProduction
Reporter, Cast
Researcher, Writing
SalesManager, Administration
SalesRepresentative, Administration
ScriptCoordinator, Writing
ScriptEditor, Writing
SeniorProducer, CreativeDirection
SocialMediaManager, Community
Songwriter, Writing
SoundDesigner, AudioPostProduction
StoryEditor, Writing
StudioCoordinator, AudioProduction
TechnicalDirector, AudioProduction
TechnicalManager, AudioProduction
ThemeMusic, AudioPostProduction
Transcriber, Writing
Translator, Writing
VideoEditor, VideoPostProduction
VoiceActor, Cast
Writer, Writing
WritingEditor, Writing ]
/// <inheritdoc />
override this.ToString() =
match this with
| Announcer -> "Announcer"
| AssistantCamera -> "Assistant Camera"
| AssistantDirector -> "Assistant Director"
| AssistantEditor -> "Assistant Editor"
| AssociateProducer -> "Associate Producer"
| AudioEditor -> "Audio Editor"
| AudioEngineer -> "Audio Engineer"
| Author -> "Author"
| BookingCoordinator -> "Booking Coordinator"
| CameraGrip -> "Camera Grip"
| CameraOperator -> "Camera Operator"
| CoHost -> "Co-Host"
| Composer -> "Composer"
| Consultant -> "Consultant"
| ContentManager -> "Content Manager"
| CoverArtDesigner -> "Cover Art Designer"
| CoWriter -> "Co-Writer"
| CreativeDirector -> "Creative Director"
| DevelopmentProducer -> "Development Producer"
| Director -> "Director"
| EditorialDirector -> "Editorial Director"
| ExecutiveProducer -> "Executive Producer"
| FactChecker -> "Fact Checker"
| FoleyArtist -> "Foley Artist"
| GraphicDesigner -> "Graphic Designer"
| Guest -> "Guest"
| GuestHost -> "Guest Host"
| GuestWriter -> "Guest Writer"
| Host -> "Host"
| Intern -> "Intern"
| LightingDesigner -> "Lighting Designer"
| Logger -> "Logger"
| ManagingEditor -> "Managing Editor"
| MarketingManager -> "Marketing Manager"
| MusicContributor -> "Music Contributor"
| MusicProduction -> "Music Production"
| Narrator -> "Narrator"
| PostProductionEngineer -> "Post Production Engineer"
| Producer -> "Producer"
| ProductionAssistant -> "Production Assistant"
| ProductionCoordinator -> "Production Coordinator"
| RemoteRecordingEngineer -> "Remote Recording Engineer"
| Reporter -> "Reporter"
| Researcher -> "Researcher"
| SalesManager -> "Sales Manager"
| SalesRepresentative -> "Sales Representative"
| ScriptCoordinator -> "Script Coordinator"
| ScriptEditor -> "Script Editor"
| SeniorProducer -> "Senior Producer"
| SocialMediaManager -> "Social Media Manager"
| Songwriter -> "Songwriter"
| SoundDesigner -> "Sound Designer"
| StoryEditor -> "Story Editor"
| StudioCoordinator -> "Studio Coordinator"
| TechnicalDirector -> "Technical Director"
| TechnicalManager -> "Technical Manager"
| ThemeMusic -> "Theme Music"
| Transcriber -> "Transcriber"
| Translator -> "Translator"
| VideoEditor -> "Editor (Video)"
| VoiceActor -> "Voice Actor"
| Writer -> "Writer"
| WritingEditor -> "Editor (Writing)"
/// <summary>Get the display name of the role</summary>
/// <remarks>Both Video Production and Writing have an "Editor"; this is how it should be rendered in RSS feeds and
/// in the UI</remarks>
member this.ToDisplay() =
match this with VideoEditor | WritingEditor -> "Editor" | _ -> string this
/// <summary>Get the <c>PersonGroup</c> to which this role belongs</summary>
member this.Group =
PersonRole.GroupXref |> List.find (fun it -> fst it = this) |> snd
open System
/// <summary>An identifier for a person</summary>
[<Struct>]
type PersonId =
| PersonId of string
/// <summary>An empty person ID</summary>
static member Empty = PersonId ""
/// <summary>Create a new person ID</summary>
/// <returns>A new person ID</returns>
static member Create() =
Convert.ToBase64String(Guid.NewGuid().ToByteArray()).Replace('/', '_').Replace('+', '-')[..21] |> PersonId
/// <inheritdoc />
override this.ToString() =
match this with PersonId it -> it
/// <summary>The combination of a person and a role</summary>
[<CLIMutable>]
type PersonAssignment = {
/// <summary>The ID of the person to whom this assignment applies</summary>
PersonId: PersonId
/// <summary>The role to which this person is assigned</summary>
Role: PersonRole
}
open NodaTime.Text
/// <summary>A podcast episode</summary>
type Episode = {
/// <summary>The URL to the media file for the episode (may be permalink)</summary>
Media: string
/// <summary>The length of the media file, in bytes</summary>
Length: int64
/// <summary>The duration of the episode</summary>
Duration: Duration option
/// <summary>The media type of the file (overrides podcast default if present)</summary>
MediaType: string option
/// <summary>The URL to the image file for this episode (overrides podcast image if present, may be permalink)</summary>
ImageUrl: string option
/// <summary>A subtitle for this episode</summary>
Subtitle: string option
/// <summary>This episode's explicit rating (overrides podcast rating if present)</summary>
Explicit: ExplicitRating option
/// <summary>Chapters for this episode</summary>
Chapters: Chapter list option
/// <summary>A link to a chapter file</summary>
ChapterFile: string option
/// <summary>The MIME type for the chapter file</summary>
ChapterType: string option
/// <summary>Whether the chapters have locations that should be displayed as waypoints</summary>
ChapterWaypoints: bool option
/// <summary>The URL for the transcript of the episode (may be permalink)</summary>
TranscriptUrl: string option
/// <summary>The MIME type of the transcript</summary>
TranscriptType: string option
/// <summary>The language in which the transcript is written</summary>
TranscriptLang: string option
/// <summary>If true, the transcript will be declared (in the feed) to be a captions file</summary>
TranscriptCaptions: bool option
/// <summary>The season number (for serialized podcasts)</summary>
SeasonNumber: int option
/// <summary>A description of the season</summary>
SeasonDescription: string option
/// <summary>The episode number</summary>
EpisodeNumber: double option
/// <summary>A description of the episode</summary>
EpisodeDescription: string option
/// <summary>The people and roles for this episode</summary>
People: PersonAssignment list option
} with
/// <summary>An empty episode</summary>
static member Empty =
{ Media = ""
Length = 0L
Duration = None
MediaType = None
ImageUrl = None
Subtitle = None
Explicit = None
Chapters = None
ChapterFile = None
ChapterType = None
ChapterWaypoints = None
TranscriptUrl = None
TranscriptType = None
TranscriptLang = None
TranscriptCaptions = None
SeasonNumber = None
SeasonDescription = None
EpisodeNumber = None
EpisodeDescription = None
People = None }
/// <summary>Format a duration for an episode</summary>
/// <returns>A duration formatted in hours, minutes, and seconds</returns>
member this.FormatDuration() =
this.Duration |> Option.map (DurationPattern.CreateWithInvariantCulture("H:mm:ss").Format)
/// <summary>PodcastIndex.org podcast:medium allowed values</summary>
[<Struct>]
type PodcastMedium =
| Podcast
| Music
| Video
| Film
| Audiobook
| Newsletter
| Blog
/// <summary>Parse a string into a podcast medium</summary>
/// <param name="medium">The string to be parsed</param>
/// <returns>The <c>PodcastMedium</c> parsed from the string</returns>
/// <exception cref="InvalidArgumentException">If the string is not valid</exception>
static member Parse medium =
match medium with
| "podcast" -> Podcast
| "music" -> Music
| "video" -> Video
| "film" -> Film
| "audiobook" -> Audiobook
| "newsletter" -> Newsletter
| "blog" -> Blog
| _ -> invalidArg (nameof medium) $"{medium} is not a valid podcast medium"
/// <inheritdoc />
override this.ToString() =
match this with
| Podcast -> "podcast"
| Music -> "music"
| Video -> "video"
| Film -> "film"
| Audiobook -> "audiobook"
| Newsletter -> "newsletter"
| Blog -> "blog"
/// <summary>Options for a feed that describes a podcast</summary>
[<CLIMutable; NoComparison; NoEquality>]
type PodcastOptions = {
/// <summary>The title of the podcast</summary>
Title: string
/// <summary>A subtitle for the podcast</summary>
Subtitle: string option
/// <summary>The number of items in the podcast feed</summary>
ItemsInFeed: int
/// <summary>A summary of the podcast (iTunes field)</summary>
Summary: string
/// <summary>The display name of the podcast author (iTunes field)</summary>
DisplayedAuthor: string
/// <summary>The e-mail address of the user who registered the podcast at iTunes</summary>
Email: string
/// <summary>The link to the image for the podcast</summary>
/// <remarks>Non-absolute URLs will be treated as a permalink</remarks>
ImageUrl: string
/// <summary>The category from Apple Podcasts (iTunes) under which this podcast is categorized</summary>
AppleCategory: string
/// <summary>
/// A further refinement of the categorization of this podcast (Apple Podcasts/iTunes field / values)
/// </summary>
AppleSubcategory: string option
/// <summary>The explictness rating (iTunes field)</summary>
Explicit: ExplicitRating
/// <summary>The default media type for files in this podcast</summary>
DefaultMediaType: string option
/// <summary>
/// The base URL for relative URL media files for this podcast (optional; defaults to web log base)
/// </summary>
MediaBaseUrl: string option
/// <summary>A GUID for this podcast</summary>
PodcastGuid: Guid option
/// <summary>A URL at which information on supporting the podcast may be found (supports permalinks)</summary>
FundingUrl: string option
/// <summary>The text to be displayed in the funding item within the feed</summary>
FundingText: string option
/// <summary>The medium (what the podcast IS, not what it is ABOUT)</summary>
Medium: PodcastMedium option
/// <summary>The people and roles for the podcast overall</summary>
People: PersonAssignment list option
} with
/// <summary>A default set of podcast options</summary>
static member Empty =
{ Title = ""
Subtitle = None
ItemsInFeed = 0
Summary = ""
DisplayedAuthor = ""
Email = ""
ImageUrl = ""
AppleCategory = ""
AppleSubcategory = None
Explicit = No
DefaultMediaType = None
MediaBaseUrl = None
PodcastGuid = None
FundingUrl = None
FundingText = None
Medium = None
People = None }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+111 -11
View File
@@ -6,6 +6,38 @@ open MyWebLog
open MyWebLog.Converters.Json
open Newtonsoft.Json
/// Unit tests for the AutoHtmxTypeConverter type
let accessLevelConverterTests = testList "AccessLevelConverter" [
let opts = JsonSerializerSettings()
opts.Converters.Add(AccessLevelConverter())
test "succeeds when serializing" {
let after = JsonConvert.SerializeObject(Editor, opts)
Expect.equal after "\"Editor\"" "Access Level serialized incorrectly"
}
test "succeeds when deserializing" {
let after = JsonConvert.DeserializeObject<AccessLevel>("\"Author\"", opts)
Expect.equal after ContentAuthor "Access Level deserialized incorrectly"
}
]
/// Unit tests for the AutoHtmxTypeConverter type
let autoHtmxTypeConverterTests = testList "AutoHtmxTypeConverter" [
let opts = JsonSerializerSettings()
opts.Converters.Add(AutoHtmxTypeConverter())
test "succeeds when serializing" {
let after = JsonConvert.SerializeObject(AutoHtmax, opts)
Expect.equal after "\"Max\"" "Auto htmx type serialized incorrectly"
}
test "succeeds when deserializing a string" {
let after = JsonConvert.DeserializeObject<AutoHtmxType>("\"Yes\"", opts)
Expect.equal after AutoHtmx "Auto htmx type deserialized incorrectly"
}
test "succeeds when deserializing a boolean value" {
let after = JsonConvert.DeserializeObject<AutoHtmxType>("false", opts)
Expect.equal after NoAutoHtmx "Auto htmx type deserialized incorrectly"
}
]
/// Unit tests for the CategoryIdConverter type
let categoryIdConverterTests = testList "CategoryIdConverter" [
let opts = JsonSerializerSettings()
@@ -16,7 +48,7 @@ let categoryIdConverterTests = testList "CategoryIdConverter" [
}
test "succeeds when deserializing" {
let after = JsonConvert.DeserializeObject<CategoryId>("\"test-cat-id\"", opts)
Expect.equal after (CategoryId "test-cat-id") "Category ID not serialized incorrectly"
Expect.equal after (CategoryId "test-cat-id") "Category ID deserialized incorrectly"
}
]
@@ -90,7 +122,7 @@ let explicitRatingConverterTests = testList "ExplicitRatingConverter" [
}
]
/// Unit tests for the MarkupText type
/// Unit tests for the MarkupTextConverter type
let markupTextConverterTests = testList "MarkupTextConverter" [
let opts = JsonSerializerSettings()
opts.Converters.Add(MarkupTextConverter())
@@ -104,6 +136,34 @@ let markupTextConverterTests = testList "MarkupTextConverter" [
}
]
/// Unit tests for the OpenGraphTypeConverter type
let openGraphTypeConverterTests = testList "OpenGraphTypeConverter" [
let opts = JsonSerializerSettings()
opts.Converters.Add(OpenGraphTypeConverter())
test "succeeds when serializing" {
let after = JsonConvert.SerializeObject(VideoTvShow, opts)
Expect.equal after "\"video.tv_show\"" "OpenGraph type serialized incorrectly"
}
test "succeeds when deserializing" {
let after = JsonConvert.DeserializeObject<OpenGraphType>("\"book\"", opts)
Expect.equal after Book "OpenGraph type deserialized incorrectly"
}
]
/// Unit tests for the PageIdConverter type
let pageIdConverterTests = testList "PageIdConverter" [
let opts = JsonSerializerSettings()
opts.Converters.Add(PageIdConverter())
test "succeeds when serializing" {
let after = JsonConvert.SerializeObject(PageId "test-page", opts)
Expect.equal after "\"test-page\"" "Page ID serialized incorrectly"
}
test "succeeds when deserializing" {
let after = JsonConvert.DeserializeObject<PageId>("\"page-test\"", opts)
Expect.equal after (PageId "page-test") "Page ID deserialized incorrectly"
}
]
/// Unit tests for the PermalinkConverter type
let permalinkConverterTests = testList "PermalinkConverter" [
let opts = JsonSerializerSettings()
@@ -118,17 +178,45 @@ let permalinkConverterTests = testList "PermalinkConverter" [
}
]
/// Unit tests for the PageIdConverter type
let pageIdConverterTests = testList "PageIdConverter" [
/// Unit tests for the PersonGroupConverter type
let personGroupConverterTests = testList "PersonGroupConverter" [
let opts = JsonSerializerSettings()
opts.Converters.Add(PageIdConverter())
opts.Converters.Add(PersonGroupConverter())
test "succeeds when serializing" {
let after = JsonConvert.SerializeObject(PageId "test-page", opts)
Expect.equal after "\"test-page\"" "Page ID serialized incorrectly"
let after = JsonConvert.SerializeObject(Visuals, opts)
Expect.equal after "\"Visuals\"" "PersonGroup serialized incorrectly"
}
test "succeeds when deserializing" {
let after = JsonConvert.DeserializeObject<PageId>("\"page-test\"", opts)
Expect.equal after (PageId "page-test") "Page ID deserialized incorrectly"
let after = JsonConvert.DeserializeObject<PersonGroup>("\"Cast\"", opts)
Expect.equal after Cast "PersonGroup deserialized incorrectly"
}
]
/// Unit tests for the PersonIdConverter type
let personIdConverterTests = testList "PersonIdConverter" [
let opts = JsonSerializerSettings()
opts.Converters.Add(PersonIdConverter())
test "succeeds when serializing" {
let after = JsonConvert.SerializeObject(PersonId "person-x", opts)
Expect.equal after "\"person-x\"" "Person ID serialized incorrectly"
}
test "succeeds when deserializing" {
let after = JsonConvert.DeserializeObject<PersonId>("\"masked\"", opts)
Expect.equal after (PersonId "masked") "Person ID deserialized incorrectly"
}
]
/// Unit tests for the PersonRoleConverter type
let personRoleConverterTests = testList "PersonRoleConverter" [
let opts = JsonSerializerSettings()
opts.Converters.Add(PersonRoleConverter())
test "succeeds when serializing" {
let after = JsonConvert.SerializeObject(Host, opts)
Expect.equal after "\"Host\"" "PersonRole serialized incorrectly"
}
test "succeeds when deserializing" {
let after = JsonConvert.DeserializeObject<PersonRole>("\"Co-Host\"", opts)
Expect.equal after CoHost "PersonRole deserialized incorrectly"
}
]
@@ -250,6 +338,8 @@ open NodaTime.Serialization.JsonNet
let configureTests = test "Json.configure succeeds" {
let has typ (converter: JsonConverter) = converter.GetType() = typ
let ser = configure (JsonSerializer.Create())
Expect.hasCountOf ser.Converters 1u (has typeof<AccessLevelConverter>) "Access Level converter not found"
Expect.hasCountOf ser.Converters 1u (has typeof<AutoHtmxTypeConverter>) "Auto htmx type converter not found"
Expect.hasCountOf ser.Converters 1u (has typeof<CategoryIdConverter>) "Category ID converter not found"
Expect.hasCountOf ser.Converters 1u (has typeof<CommentIdConverter>) "Comment ID converter not found"
Expect.hasCountOf ser.Converters 1u (has typeof<CommentStatusConverter>) "Comment status converter not found"
@@ -257,8 +347,12 @@ let configureTests = test "Json.configure succeeds" {
Expect.hasCountOf ser.Converters 1u (has typeof<CustomFeedSourceConverter>) "Custom feed source converter not found"
Expect.hasCountOf ser.Converters 1u (has typeof<ExplicitRatingConverter>) "Explicit rating converter not found"
Expect.hasCountOf ser.Converters 1u (has typeof<MarkupTextConverter>) "Markup text converter not found"
Expect.hasCountOf ser.Converters 1u (has typeof<PermalinkConverter>) "Permalink converter not found"
Expect.hasCountOf ser.Converters 1u (has typeof<OpenGraphTypeConverter>) "OpenGraph type converter not found"
Expect.hasCountOf ser.Converters 1u (has typeof<PageIdConverter>) "Page ID converter not found"
Expect.hasCountOf ser.Converters 1u (has typeof<PermalinkConverter>) "Permalink converter not found"
Expect.hasCountOf ser.Converters 1u (has typeof<PersonGroupConverter>) "PersonGroup converter not found"
Expect.hasCountOf ser.Converters 1u (has typeof<PersonIdConverter>) "Person ID converter not found"
Expect.hasCountOf ser.Converters 1u (has typeof<PersonRoleConverter>) "PersonRole converter not found"
Expect.hasCountOf ser.Converters 1u (has typeof<PodcastMediumConverter>) "Podcast medium converter not found"
Expect.hasCountOf ser.Converters 1u (has typeof<PostIdConverter>) "Post ID converter not found"
Expect.hasCountOf ser.Converters 1u (has typeof<TagMapIdConverter>) "Tag map ID converter not found"
@@ -275,6 +369,8 @@ let configureTests = test "Json.configure succeeds" {
/// All tests for the Data.Converters file
let all = testList "Converters" [
accessLevelConverterTests
autoHtmxTypeConverterTests
categoryIdConverterTests
commentIdConverterTests
commentStatusConverterTests
@@ -282,8 +378,12 @@ let all = testList "Converters" [
customFeedSourceConverterTests
explicitRatingConverterTests
markupTextConverterTests
permalinkConverterTests
openGraphTypeConverterTests
pageIdConverterTests
permalinkConverterTests
personGroupConverterTests
personIdConverterTests
personRoleConverterTests
podcastMediumConverterTests
postIdConverterTests
tagMapIdConverterTests
+5 -3
View File
@@ -1,6 +1,6 @@
/// <summary>
/// Integration tests for <see cref="IPageData" /> implementations
/// </summary>
/// </summary>
module PageDataTests
open System
@@ -35,8 +35,9 @@ let ``Add succeeds`` (data: IData) = task {
Text = "<h1>A new page</h1>"
Metadata = [ { Name = "Meta Item"; Value = "Meta Value" } ]
PriorPermalinks = [ Permalink "2024/the-new-page.htm" ]
Revisions = [ { AsOf = Noda.epoch + Duration.FromDays 3; Text = Html "<h1>A new page</h1>" } ] }
do! data.Page.Add page
Revisions = [ { AsOf = Noda.epoch + Duration.FromDays 3; Text = Html "<h1>A new page</h1>" } ]
OpenGraph = Some { OpenGraphProperties.Empty with Type = Book } }
do! data.Page.Add page
let! stored = data.Page.FindFullById (PageId "added-page") (WebLogId "test")
Expect.isSome stored "The page should have been added"
let pg = stored.Value
@@ -53,6 +54,7 @@ let ``Add succeeds`` (data: IData) = task {
Expect.equal pg.Metadata page.Metadata "Metadata not saved properly"
Expect.equal pg.PriorPermalinks page.PriorPermalinks "Prior permalinks not saved properly"
Expect.equal pg.Revisions page.Revisions "Revisions not saved properly"
Expect.equal pg.OpenGraph page.OpenGraph "OpenGraph properties not saved properly"
}
let ``All succeeds`` (data: IData) = task {
+103
View File
@@ -0,0 +1,103 @@
/// <summary>Integration tests for <see cref="IPersonData" /> implementations</summary>
module PersonDataTests
open Expecto
open MyWebLog
open MyWebLog.Data
/// The ID of the root web log
let private rootId = CategoryDataTests.rootId
/// The person ID for "Crackpot"
let private crackpotPersonId = PersonId "Q-6ElUj4cEyg5_wQeMpH_A"
/// The person ID for "Buzzkill" (RIP, JCD)
let private buzzkillPersonId = PersonId "a8M4gN58R0evXnJEt9j95w"
let ``Add succeeds`` (data: IData) = task {
let person =
{ Id = PersonId "person-x"
WebLogId = WebLogId "web-log-x"
Name = "Anonny Moose"
Url = None
ImageUrl = Some "sunglasses.jpg" }
do! data.Person.Add person
let! stored = data.Person.FindById person.Id person.WebLogId
Expect.isSome stored "The added person should have been retrieved"
let it = stored.Value
Expect.equal it.Id person.Id "ID not saved properly"
Expect.equal it.WebLogId person.WebLogId "Web Log ID not saved properly"
Expect.equal it.Name person.Name "Name not saved properly"
Expect.equal it.Url person.Url "URL not saved properly"
Expect.equal it.ImageUrl person.ImageUrl "Image URL not saved properly"
}
let ``FindByAssignment succeeds when assignments are found`` (data: IData) = task {
let assignments =
[ { PersonId = crackpotPersonId; Role = Producer }; { PersonId = buzzkillPersonId; Role = Producer } ]
let! people = data.Person.FindByAssignment assignments
Expect.hasLength people 2 "There should have been two people returned"
Expect.equal (people |> List.head).Name "Buzzkill" "The list was not sorted correctly"
}
let ``FindByAssignment succeeds when no assignments are found`` (data: IData) = task {
let assignments =
[ { PersonId = PersonId "missing"; Role = Guest }
{ PersonId = PersonId "still-missing"; Role = GuestHost } ]
let! people = data.Person.FindByAssignment assignments
Expect.hasLength people 0 "There should not have been any people returned"
}
let ``FindById succeeds when a person is found`` (data: IData) = task {
let! person = data.Person.FindById buzzkillPersonId rootId
Expect.isSome person "There should have been a person returned"
let it = person.Value
Expect.equal it.Id buzzkillPersonId "ID retrieved incorrectly"
Expect.equal it.WebLogId rootId "Web Log ID retrieved incorrectly"
Expect.equal it.Name "Buzzkill" "Name retrieved incorrectly"
Expect.isNone it.Url "URL should have been blank"
Expect.isNone it.ImageUrl "Image URL should have been blank"
}
let ``FindById succeeds when a person is not found (incorrect weblog)`` (data: IData) = task {
let! person = data.Person.FindById crackpotPersonId (WebLogId "incorrect")
Expect.isNone person "There should not have been a person returned"
}
let ``FindById succeeds when a person is not found (bad person ID)`` (data: IData) = task {
let! person = data.Person.FindById (PersonId "bad") rootId
Expect.isNone person "There should not have been a person returned"
}
let ``FindByWebLog succeeds when people are found`` (data: IData) = task {
let! people = data.Person.FindByWebLog rootId
Expect.hasLength people 2 "There should have been 2 people returned"
Expect.equal (people |> List.head).Name "Buzzkill" "The people were not sorted correctly"
}
let ``FindByWebLog succeeds when no people are found`` (data: IData) = task {
let! people = data.Person.FindByWebLog (WebLogId "no-folks")
Expect.hasLength people 0 "There should have been no people returned"
}
let ``Update succeeds when person is updated`` (data: IData) = task {
let! person = data.Person.FindById crackpotPersonId rootId
Expect.isSome person "The original person should have been returned"
do! data.Person.Update { person.Value with Name = "Crackpot!" }
let! updated = data.Person.FindById crackpotPersonId rootId
Expect.isSome updated "The updated person should have been returned"
Expect.equal updated.Value.Name "Crackpot!" "Name was not updated"
// Put the name back
do! data.Person.Update person.Value
}
let ``Update succeeds when person is not updated`` (data: IData) = task {
let! person = data.Person.FindById crackpotPersonId rootId
Expect.isSome person "The original person should have been returned"
do! data.Person.Update { person.Value with Name = "New Name"; WebLogId = WebLogId "invalid" }
let! updated = data.Person.FindById crackpotPersonId rootId
Expect.isSome updated "The updated person should have been returned"
Expect.equal updated.Value.Name "Crackpot" "The update should not have occurred"
}
// TODO: write delete tests when they cover podcasts and episodes as well
+16 -5
View File
@@ -1,6 +1,4 @@
/// <summary>
/// Integration tests for <see cref="IPostData" /> implementations
/// </summary>
/// <summary>Integration tests for <see cref="IPostData" /> implementations</summary>
module PostDataTests
open System
@@ -39,6 +37,12 @@ let private testPost1Published = Instant.FromDateTimeOffset(DateTimeOffset.Parse
/// The category IDs for "Spitball" (parent) and "Moonshot"
let private testCatIds = [ CategoryId "jw6N69YtTEWVHAO33jHU-w"; CategoryId "ScVpyu1e7UiP7bDdge3ZEw" ]
/// The person ID for "Crackpot"
let private crackpotPersonId = PersonId "Q-6ElUj4cEyg5_wQeMpH_A"
/// The person ID for "Buzzkill" (RIP, JCD)
let private buzzkillPersonId = PersonId "a8M4gN58R0evXnJEt9j95w"
/// Ensure that a list of posts has text for each post
let private ensureHasText (posts: Post list) =
for post in posts do Expect.isNotEmpty post.Text $"Text should not be blank (post ID {post.Id})"
@@ -54,7 +58,7 @@ let ``Add succeeds`` (data: IData) = task {
{ Id = PostId "a-new-post"
WebLogId = WebLogId "test"
AuthorId = WebLogUserId "test-author"
Status = Published
Status = Published
Title = "A New Test Post"
Permalink = Permalink "2020/test-post.html"
PublishedOn = Some (Noda.epoch + Duration.FromMinutes 1L)
@@ -66,7 +70,8 @@ let ``Add succeeds`` (data: IData) = task {
Episode = Some { Episode.Empty with Media = "test-ep.mp3" }
Metadata = [ { Name = "Meta"; Value = "Data" } ]
PriorPermalinks = [ Permalink "2020/test-post-a.html" ]
Revisions = [ { AsOf = Noda.epoch + Duration.FromMinutes 1L; Text = Html "<p>Test text here" } ] }
Revisions = [ { AsOf = Noda.epoch + Duration.FromMinutes 1L; Text = Html "<p>Test text here" } ]
OpenGraph = Some { OpenGraphProperties.Empty with Type = VideoMovie } }
do! data.Post.Add post
let! stored = data.Post.FindFullById post.Id post.WebLogId
Expect.isSome stored "The added post should have been retrieved"
@@ -87,6 +92,7 @@ let ``Add succeeds`` (data: IData) = task {
Expect.equal it.Metadata post.Metadata "Metadata items not saved properly"
Expect.equal it.PriorPermalinks post.PriorPermalinks "Prior permalinks not saved properly"
Expect.equal it.Revisions post.Revisions "Revisions not saved properly"
Expect.equal it.OpenGraph post.OpenGraph "OpenGraph properties not saved correctly"
}
let ``CountByStatus succeeds`` (data: IData) = task {
@@ -127,6 +133,11 @@ let ``FindById succeeds when a post is found`` (data: IData) = task {
Expect.equal ep.SeasonDescription (Some "The First Season") "Episode season description is incorrect"
Expect.equal ep.EpisodeNumber (Some 1.) "Episode number is incorrect"
Expect.equal ep.EpisodeDescription (Some "The first episode ever!") "Episode description is incorrect"
Expect.isSome ep.People "There should have been people on the episode"
let ppl = ep.People.Value
Expect.hasLength ppl 2 "There should have been 2 people on the episode"
Expect.contains ppl { PersonId = crackpotPersonId; Role = CoHost } "Crackpot not found on episode"
Expect.contains ppl { PersonId = buzzkillPersonId; Role = VoiceActor } "Buzzkill not found on episode"
Expect.equal
it.Metadata
[ { Name = "Density"; Value = "Non-existent" }; { Name = "Intensity"; Value = "Low" } ]
@@ -224,6 +224,40 @@ let private pageTests = testList "Page" [
]
]
/// Integration tests for the Person implementation in PostgreSQL
let private personTests = testList "Person" [
testTask "Add succeeds" {
do! PersonDataTests.``Add succeeds`` (mkData ())
}
testTask "FindByAssignment succeeds when assignments are found" {
do! PersonDataTests.``FindByAssignment succeeds when assignments are found`` (mkData ())
}
testTask "FindByAssignment succeeds when no assignments are found" {
do! PersonDataTests.``FindByAssignment succeeds when no assignments are found`` (mkData ())
}
testTask "FindById succeeds when a person is found" {
do! PersonDataTests.``FindById succeeds when a person is found`` (mkData ())
}
testTask "FindById succeeds when a person is not found (incorrect weblog)" {
do! PersonDataTests.``FindById succeeds when a person is not found (incorrect weblog)`` (mkData ())
}
testTask "FindById succeeds when a person is not found (bad person ID)" {
do! PersonDataTests.``FindById succeeds when a person is not found (bad person ID)`` (mkData ())
}
testTask "FindByWebLog succeeds when people are found" {
do! PersonDataTests.``FindByWebLog succeeds when people are found`` (mkData ())
}
testTask "FindByWebLog succeeds when no people are found" {
do! PersonDataTests.``FindByWebLog succeeds when no people are found`` (mkData ())
}
testTask "Update succeeds when person is updated" {
do! PersonDataTests.``Update succeeds when person is updated`` (mkData ())
}
testTask "Update succeeds when person is not updated" {
do! PersonDataTests.``Update succeeds when person is not updated`` (mkData ())
}
]
/// Integration tests for the Post implementation in PostgreSQL
let private postTests = testList "Post" [
testTask "Add succeeds" {
@@ -711,6 +745,7 @@ let all =
[ environmentSetUp
categoryTests
pageTests
personTests
postTests
tagMapTests
themeTests
@@ -218,6 +218,40 @@ let private pageTests = testList "Page" [
]
]
/// Integration tests for the Post implementation in RethinkDB
let private personTests = testList "Person" [
testTask "Add succeeds" {
do! PersonDataTests.``Add succeeds`` data.Value
}
testTask "FindByAssignment succeeds when assignments are found" {
do! PersonDataTests.``FindByAssignment succeeds when assignments are found`` data.Value
}
testTask "FindByAssignment succeeds when no assignments are found" {
do! PersonDataTests.``FindByAssignment succeeds when no assignments are found`` data.Value
}
testTask "FindById succeeds when a person is found" {
do! PersonDataTests.``FindById succeeds when a person is found`` data.Value
}
testTask "FindById succeeds when a person is not found (incorrect weblog)" {
do! PersonDataTests.``FindById succeeds when a person is not found (incorrect weblog)`` data.Value
}
testTask "FindById succeeds when a person is not found (bad person ID)" {
do! PersonDataTests.``FindById succeeds when a person is not found (bad person ID)`` data.Value
}
testTask "FindByWebLog succeeds when people are found" {
do! PersonDataTests.``FindByWebLog succeeds when people are found`` data.Value
}
testTask "FindByWebLog succeeds when no people are found" {
do! PersonDataTests.``FindByWebLog succeeds when no people are found`` data.Value
}
testTask "Update succeeds when person is updated" {
do! PersonDataTests.``Update succeeds when person is updated`` data.Value
}
testTask "Update succeeds when person is not updated" {
do! PersonDataTests.``Update succeeds when person is not updated`` data.Value
}
]
/// Integration tests for the Post implementation in RethinkDB
let private postTests = testList "Post" [
testTask "Add succeeds" {
@@ -693,6 +727,7 @@ let all =
[ environmentSetUp
categoryTests
pageTests
personTests
postTests
tagMapTests
themeTests
@@ -326,6 +326,60 @@ let private pageTests = testList "Page" [
]
]
/// Integration tests for the Person implementation in SQLite
let private personTests = testList "Person" [
testTask "Add succeeds" {
let data = mkData ()
try do! PersonDataTests.``Add succeeds`` data
finally dispose data
}
testTask "FindByAssignment succeeds when assignments are found" {
let data = mkData ()
try do! PersonDataTests.``FindByAssignment succeeds when assignments are found`` data
finally dispose data
}
testTask "FindByAssignment succeeds when no assignments are found" {
let data = mkData ()
try do! PersonDataTests.``FindByAssignment succeeds when no assignments are found`` data
finally dispose data
}
testTask "FindById succeeds when a person is found" {
let data = mkData ()
try do! PersonDataTests.``FindById succeeds when a person is found`` data
finally dispose data
}
testTask "FindById succeeds when a person is not found (incorrect weblog)" {
let data = mkData ()
try do! PersonDataTests.``FindById succeeds when a person is not found (incorrect weblog)`` data
finally dispose data
}
testTask "FindById succeeds when a person is not found (bad person ID)" {
let data = mkData ()
try do! PersonDataTests.``FindById succeeds when a person is not found (bad person ID)`` data
finally dispose data
}
testTask "FindByWebLog succeeds when people are found" {
let data = mkData ()
try do! PersonDataTests.``FindByWebLog succeeds when people are found`` data
finally dispose data
}
testTask "FindByWebLog succeeds when no people are found" {
let data = mkData ()
try do! PersonDataTests.``FindByWebLog succeeds when no people are found`` data
finally dispose data
}
testTask "Update succeeds when person is updated" {
let data = mkData ()
try do! PersonDataTests.``Update succeeds when person is updated`` data
finally dispose data
}
testTask "Update succeeds when person is not updated" {
let data = mkData ()
try do! PersonDataTests.``Update succeeds when person is not updated`` data
finally dispose data
}
]
/// Integration tests for the Post implementation in SQLite
let private postTests = testList "Post" [
testTask "Add succeeds" {
@@ -1043,6 +1097,7 @@ let all =
[ environmentSetUp
categoryTests
pageTests
personTests
postTests
tagMapTests
themeTests
+16 -11
View File
@@ -1,6 +1,4 @@
/// <summary>
/// Integration tests for <see cref="IWebLogData" /> implementations
/// </summary>
/// <summary>Integration tests for <see cref="IWebLogData" /> implementations</summary>
module WebLogDataTests
open System
@@ -25,14 +23,15 @@ let ``Add succeeds`` (data: IData) = task {
Rss =
{ IsFeedEnabled = true
FeedName = "my-feed.xml"
ItemsInFeed = None
ItemsInFeed = None
IsCategoryEnabled = false
IsTagEnabled = false
Copyright = Some "go for it"
CustomFeeds = [] }
AutoHtmx = true
AutoHtmx = AutoHtmax
Uploads = Disk
RedirectRules = [ { From = "/here"; To = "/there"; IsRegex = false } ] }
RedirectRules = [ { From = "/here"; To = "/there"; IsRegex = false } ]
AutoOpenGraph = false }
let! webLog = data.WebLog.FindById (WebLogId "new-weblog")
Expect.isSome webLog "The web log should have been returned"
let it = webLog.Value
@@ -45,9 +44,10 @@ let ``Add succeeds`` (data: IData) = task {
Expect.equal it.ThemeId (ThemeId "default") "Theme ID is incorrect"
Expect.equal it.UrlBase "https://example.com/new" "URL base is incorrect"
Expect.equal it.TimeZone "America/Los_Angeles" "Time zone is incorrect"
Expect.isTrue it.AutoHtmx "Auto htmx flag is incorrect"
Expect.equal it.AutoHtmx AutoHtmax "Auto htmx type is incorrect"
Expect.equal it.Uploads Disk "Upload destination is incorrect"
Expect.equal it.RedirectRules [ { From = "/here"; To = "/there"; IsRegex = false } ] "Redirect rules are incorrect"
Expect.isFalse it.AutoOpenGraph "Auto OpenGraph flag is incorrect"
let rss = it.Rss
Expect.isTrue rss.IsFeedEnabled "Is feed enabled flag is incorrect"
Expect.equal rss.FeedName "my-feed.xml" "Feed name is incorrect"
@@ -89,7 +89,7 @@ let ``FindById succeeds when a web log is found`` (data: IData) = task {
Expect.equal it.ThemeId (ThemeId "default") "Theme ID is incorrect"
Expect.equal it.UrlBase "http://localhost:8081" "URL base is incorrect"
Expect.equal it.TimeZone "America/Denver" "Time zone is incorrect"
Expect.isTrue it.AutoHtmx "Auto htmx flag is incorrect"
Expect.equal it.AutoHtmx AutoHtmx "Auto htmx type is incorrect"
Expect.equal it.Uploads Database "Upload destination is incorrect"
Expect.isEmpty it.RedirectRules "Redirect rules are incorrect"
let rss = it.Rss
@@ -110,7 +110,7 @@ let ``FindById succeeds when a web log is found`` (data: IData) = task {
Expect.equal pod.Summary "All things that happen in the domain root" "Podcast summary is incorrect"
Expect.equal pod.DisplayedAuthor "Podcaster Extraordinaire" "Podcast author is incorrect"
Expect.equal pod.Email "podcaster@example.com" "Podcast e-mail is incorrect"
Expect.equal pod.ImageUrl (Permalink "images/cover-art.png") "Podcast image URL is incorrect"
Expect.equal pod.ImageUrl "images/cover-art.png" "Podcast image URL is incorrect"
Expect.equal pod.AppleCategory "Fiction" "Podcast Apple category is incorrect"
Expect.equal pod.AppleSubcategory (Some "Drama") "Podcast Apple subcategory is incorrect"
Expect.equal pod.Explicit No "Podcast explicit rating is incorrect"
@@ -120,6 +120,11 @@ let ``FindById succeeds when a web log is found`` (data: IData) = task {
Expect.equal pod.FundingUrl (Some "https://example.com/support-us") "Podcast funding URL is incorrect"
Expect.equal pod.FundingText (Some "Support Our Work") "Podcast funding text is incorrect"
Expect.equal pod.Medium (Some Newsletter) "Podcast medium is incorrect"
Expect.isSome pod.People "There should have been people associated with the podcast"
let ppl = pod.People.Value
Expect.hasLength ppl 2 "There should have been 2 people associatged with the podcast"
Expect.contains ppl { PersonId = PersonId "Q-6ElUj4cEyg5_wQeMpH_A"; Role = Host } "Crackpot not found"
Expect.contains ppl { PersonId = PersonId "a8M4gN58R0evXnJEt9j95w"; Role = Host } "Buzzkill not found :("
}
let ``FindById succeeds when a web log is not found`` (data: IData) = task {
@@ -163,10 +168,10 @@ let ``UpdateRssOptions succeeds when the web log does not exist`` (data: IData)
let ``UpdateSettings succeeds when the web log exists`` (data: IData) = task {
let! webLog = data.WebLog.FindById rootId
Expect.isSome webLog "The root web log should have been returned"
do! data.WebLog.UpdateSettings { webLog.Value with AutoHtmx = false; Subtitle = None }
do! data.WebLog.UpdateSettings { webLog.Value with AutoHtmx = NoAutoHtmx; Subtitle = None }
let! updated = data.WebLog.FindById rootId
Expect.isSome updated "The updated web log should have been returned"
Expect.isFalse updated.Value.AutoHtmx "Auto htmx flag not updated correctly"
Expect.equal updated.Value.AutoHtmx NoAutoHtmx "Auto htmx type not updated correctly"
Expect.isNone updated.Value.Subtitle "Subtitle not updated correctly"
}
@@ -33,7 +33,7 @@ let ``Add succeeds`` (data: IData) = task {
PreferredName = "n00b"
PasswordHash = "hashed-password"
Url = Some "https://example.com/~new"
AccessLevel = Author
AccessLevel = ContentAuthor
CreatedOn = Noda.epoch + Duration.FromDays 365
LastSeenOn = None }
let! user = data.WebLogUser.FindById newId rootId
@@ -47,7 +47,7 @@ let ``Add succeeds`` (data: IData) = task {
Expect.equal it.PreferredName "n00b" "Preferred name is incorrect"
Expect.equal it.PasswordHash "hashed-password" "Password hash is incorrect"
Expect.equal it.Url (Some "https://example.com/~new") "URL is incorrect"
Expect.equal it.AccessLevel Author "Access level is incorrect"
Expect.equal it.AccessLevel ContentAuthor "Access level is incorrect"
Expect.equal it.CreatedOn (Noda.epoch + Duration.FromDays 365) "Created on is incorrect"
Expect.isNone it.LastSeenOn "Last seen on should not have had a value"
}
@@ -34,6 +34,18 @@ let webLogTests = testList "WebLog" [
"https://my.site/blog/page.html"
"Absolute URL is incorrect"
}
testList "UrlToAbsolute" [
test "succeeds for relative URL" {
Expect.equal
({ WebLog.Empty with UrlBase = "https://my.site" }.UrlToAbsolute "blog/page.html")
"https://my.site/blog/page.html"
"Absolute URL is incorrect"
}
test "succeeds for absolute URL" {
Expect.equal
(WebLog.Empty.UrlToAbsolute "https://test.units") "https://test.units" "Absolute URL is incorrect"
}
]
testList "RelativeUrl" [
test "succeeds for domain root URL" {
Expect.equal
@@ -0,0 +1,845 @@
module PodcastTypesTests
open System
open Expecto
open MyWebLog
open NodaTime
/// Tests for the ExplicitRating type
let explicitRatingTests = testList "ExplicitRating" [
testList "Parse" [
test "succeeds for \"yes\"" {
Expect.equal (ExplicitRating.Parse "yes") Yes "\"yes\" not parsed correctly"
}
test "succeeds for \"no\"" {
Expect.equal (ExplicitRating.Parse "no") No "\"no\" not parsed correctly"
}
test "succeeds for \"clean\"" {
Expect.equal (ExplicitRating.Parse "clean") Clean "\"clean\" not parsed correctly"
}
test "fails for unrecognized value" {
Expect.throwsT<ArgumentException>
(fun () -> ignore (ExplicitRating.Parse "maybe")) "Invalid value should have raised an exception"
}
]
testList "ToString" [
test "Yes succeeds" {
Expect.equal (string Yes) "yes" "Yes string incorrect"
}
test "No succeeds" {
Expect.equal (string No) "no" "No string incorrect"
}
test "Clean succeeds" {
Expect.equal (string Clean) "clean" "Clean string incorrect"
}
]
]
/// Tests for the Episode type
let episodeTests = testList "Episode" [
testList "FormatDuration" [
test "succeeds when no duration is specified" {
Expect.isNone (Episode.Empty.FormatDuration()) "A missing duration should have returned None"
}
test "succeeds when duration is specified" {
Expect.equal
({ Episode.Empty with
Duration = Some (Duration.FromMinutes 3L + Duration.FromSeconds 13L) }.FormatDuration())
(Some "0:03:13")
"Duration not formatted correctly"
}
test "succeeds when duration is > 10 hours" {
Expect.equal
({ Episode.Empty with Duration = Some (Duration.FromHours 11) }.FormatDuration())
(Some "11:00:00")
"Duration not formatted correctly"
}
]
]
/// Tests for the PersonGroup type
let personGroupTests = testList "PersonGroup" [
testList "Parse" [
test "succeeds for \"Administration\"" {
Expect.equal (PersonGroup.Parse "Administration") Administration "Administration not parsed correctly"
}
test "succeeds for \"Audio Post-Production\"" {
Expect.equal
(PersonGroup.Parse "Audio Post-Production")
AudioPostProduction
"Audio Post-Production not parsed correctly"
}
test "succeeds for \"Audio Production\"" {
Expect.equal
(PersonGroup.Parse "Audio Production") AudioProduction "Audio Production not parsed correctly"
}
test "succeeds for \"Cast\"" {
Expect.equal (PersonGroup.Parse "Cast") Cast "Cast not parsed correctly"
}
test "succeeds for \"Community\"" {
Expect.equal (PersonGroup.Parse "Community") Community "Community not parsed correctly"
}
test "succeeds for \"Creative Direction\"" {
Expect.equal
(PersonGroup.Parse "Creative Direction") CreativeDirection "Creative Direction not parsed correctly"
}
test "succeeds for \"Misc.\"" {
Expect.equal (PersonGroup.Parse "Misc.") Misc "Misc. not parsed correctly"
}
test "succeeds for \"Video Post-Production\"" {
Expect.equal
(PersonGroup.Parse "Video Post-Production")
VideoPostProduction
"Video Post-Production not parsed correctly"
}
test "succeeds for \"Video Production\"" {
Expect.equal
(PersonGroup.Parse "Video Production") VideoProduction "Video Production not parsed correctly"
}
test "succeeds for \"Visuals\"" {
Expect.equal (PersonGroup.Parse "Visuals") Visuals "Visuals not parsed correctly"
}
test "succeeds for \"Writing\"" {
Expect.equal (PersonGroup.Parse "Writing") Writing "Writing not parsed correctly"
}
test "fails for unrecognized value" {
Expect.throwsT<ArgumentException>
(fun () -> ignore (PersonGroup.Parse "Fan")) "Invalid value should have raised an exception"
}
]
testList "ToString" [
test "Administration succeeds" {
Expect.equal (string Administration) "Administration" "Administration string incorrect"
}
test "Audio Post-Production succeeds" {
Expect.equal (string AudioPostProduction) "Audio Post-Production" "Audio Post-Production string incorrect"
}
test "Audio Production succeeds" {
Expect.equal (string AudioProduction) "Audio Production" "Audio Production string incorrect"
}
test "Cast succeeds" {
Expect.equal (string Cast) "Cast" "Cast string incorrect"
}
test "Community succeeds" {
Expect.equal (string Community) "Community" "Community string incorrect"
}
test "Creative Direction succeeds" {
Expect.equal (string CreativeDirection) "Creative Direction" "Creative Direction string incorrect"
}
test "Misc succeeds" {
Expect.equal (string Misc) "Misc." "Misc string incorrect"
}
test "Video Post-Production succeeds" {
Expect.equal (string VideoPostProduction) "Video Post-Production" "Video Post-Production string incorrect"
}
test "Video Production succeeds" {
Expect.equal (string VideoProduction) "Video Production" "Video Production string incorrect"
}
test "Visuals succeeds" {
Expect.equal (string Visuals) "Visuals" "Visuals string incorrect"
}
test "Writing succeeds" {
Expect.equal (string Writing) "Writing" "Writing string incorrect"
}
]
]
/// Unit tests for the PersonRole type
let personRoleTests = testList "PersonRole" [
testList "Parse" [
test "succeeds for \"Announcer\"" {
Expect.equal (PersonRole.Parse "Announcer") Announcer "Announcer not parsed correctly"
}
test "succeeds for \"Assistant Camera\"" {
Expect.equal (PersonRole.Parse "Assistant Camera") AssistantCamera "Assistant Camera not parsed correctly"
}
test "succeeds for \"Assistant Director\"" {
Expect.equal
(PersonRole.Parse "Assistant Director") AssistantDirector "Assistant Director not parsed correctly"
}
test "succeeds for \"Assistant Editor\"" {
Expect.equal (PersonRole.Parse "Assistant Editor") AssistantEditor "Assistant Editor not parsed correctly"
}
test "succeeds for \"Associate Producer\"" {
Expect.equal
(PersonRole.Parse "Associate Producer") AssociateProducer "Associate Producer not parsed correctly"
}
test "succeeds for \"Audio Editor\"" {
Expect.equal (PersonRole.Parse "Audio Editor") AudioEditor "Audio Editor not parsed correctly"
}
test "succeeds for \"Audio Engineer\"" {
Expect.equal (PersonRole.Parse "Audio Engineer") AudioEngineer "Audio Engineer not parsed correctly"
}
test "succeeds for \"Author\"" {
Expect.equal (PersonRole.Parse "Author") Author "Author not parsed correctly"
}
test "succeeds for \"Booking Coordinator\"" {
Expect.equal
(PersonRole.Parse "Booking Coordinator") BookingCoordinator "Booking Coordinator not parsed correctly"
}
test "succeeds for \"Camera Grip\"" {
Expect.equal (PersonRole.Parse "Camera Grip") CameraGrip "Camera Grip not parsed correctly"
}
test "succeeds for \"Camera Operator\"" {
Expect.equal (PersonRole.Parse "Camera Operator") CameraOperator "Camera Operator not parsed correctly"
}
test "succeeds for \"Co-Host\"" {
Expect.equal (PersonRole.Parse "Co-Host") CoHost "Co-Host not parsed correctly"
}
test "succeeds for \"Composer\"" {
Expect.equal (PersonRole.Parse "Composer") Composer "Composer not parsed correctly"
}
test "succeeds for \"Consultant\"" {
Expect.equal (PersonRole.Parse "Consultant") Consultant "Consultant not parsed correctly"
}
test "succeeds for \"Content Manager\"" {
Expect.equal (PersonRole.Parse "Content Manager") ContentManager "Content Manager not parsed correctly"
}
test "succeeds for \"Cover Art Designer\"" {
Expect.equal
(PersonRole.Parse "Cover Art Designer") CoverArtDesigner "Cover Art Designer not parsed correctly"
}
test "succeeds for \"Co-Writer\"" {
Expect.equal (PersonRole.Parse "Co-Writer") CoWriter "Co-Writer not parsed correctly"
}
test "succeeds for \"Creative Director\"" {
Expect.equal
(PersonRole.Parse "Creative Director") CreativeDirector "Creative Director not parsed correctly"
}
test "succeeds for \"Development Producer\"" {
Expect.equal
(PersonRole.Parse "Development Producer")
DevelopmentProducer
"Development Producer not parsed correctly"
}
test "succeeds for \"Director\"" {
Expect.equal (PersonRole.Parse "Director") Director "Director not parsed correctly"
}
test "succeeds for \"Editorial Director\"" {
Expect.equal
(PersonRole.Parse "Editorial Director") EditorialDirector "Editorial Director not parsed correctly"
}
test "succeeds for \"Executive Producer\"" {
Expect.equal
(PersonRole.Parse "Executive Producer") ExecutiveProducer "Executive Producer not parsed correctly"
}
test "succeeds for \"Fact Checker\"" {
Expect.equal (PersonRole.Parse "Fact Checker") FactChecker "Fact Checker not parsed correctly"
}
test "succeeds for \"Foley Artist\"" {
Expect.equal (PersonRole.Parse "Foley Artist") FoleyArtist "Foley Artist not parsed correctly"
}
test "succeeds for \"Graphic Designer\"" {
Expect.equal (PersonRole.Parse "Graphic Designer") GraphicDesigner "Graphic Designer not parsed correctly"
}
test "succeeds for \"Guest\"" {
Expect.equal (PersonRole.Parse "Guest") Guest "Guest not parsed correctly"
}
test "succeeds for \"Guest Host\"" {
Expect.equal (PersonRole.Parse "Guest Host") GuestHost "Guest Host not parsed correctly"
}
test "succeeds for \"Guest Writer\"" {
Expect.equal (PersonRole.Parse "Guest Writer") GuestWriter "Guest Writer not parsed correctly"
}
test "succeeds for \"Host\"" {
Expect.equal (PersonRole.Parse "Host") Host "Host not parsed correctly"
}
test "succeeds for \"Intern\"" {
Expect.equal (PersonRole.Parse "Intern") Intern "Intern not parsed correctly"
}
test "succeeds for \"Lighting Designer\"" {
Expect.equal
(PersonRole.Parse "Lighting Designer") LightingDesigner "Lighting Designer not parsed correctly"
}
test "succeeds for \"Logger\"" {
Expect.equal (PersonRole.Parse "Logger") Logger "Logger not parsed correctly"
}
test "succeeds for \"Managing Editor\"" {
Expect.equal (PersonRole.Parse "Managing Editor") ManagingEditor "Managing Editor not parsed correctly"
}
test "succeeds for \"Marketing Manager\"" {
Expect.equal
(PersonRole.Parse "Marketing Manager") MarketingManager "Marketing Manager not parsed correctly"
}
test "succeeds for \"Music Contributor\"" {
Expect.equal
(PersonRole.Parse "Music Contributor") MusicContributor "Music Contributor not parsed correctly"
}
test "succeeds for \"Music Production\"" {
Expect.equal (PersonRole.Parse "Music Production") MusicProduction "Music Production not parsed correctly"
}
test "succeeds for \"Narrator\&