Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c2e2e9265 | ||
|
|
e17e5b79f0 | ||
|
|
5c7d34ffc7 | ||
|
|
d9e7c771ba | ||
|
|
05a810dfc6 | ||
|
|
be4f621d42 | ||
|
|
e7261f6019 | ||
|
|
2241742ab8 | ||
|
|
a380c7ea70 | ||
|
|
2f22a1a2d7 | ||
|
|
c764eed81d | ||
|
|
4a7481a98a | ||
|
|
2eb0153e46 | ||
|
|
3a19952f39 | ||
|
|
816a156d79 | ||
|
|
84025a5eb2 | ||
|
|
03dc289991 | ||
|
|
abfae6786d | ||
|
|
76f0506de7 | ||
|
|
e8ee236c1e | ||
|
|
0fcb40fbdd | ||
|
|
d31b508359 | ||
|
|
32372a3f9f | ||
|
|
b3eab09576 | ||
|
|
8e0b2df870 | ||
|
|
edb376c06c | ||
|
|
be6251b6ec | ||
|
|
fcd02ecbec | ||
|
|
9d2277ab32 | ||
|
|
7c588662d2 | ||
|
|
84444e4789 | ||
|
|
0de1bff190 | ||
|
|
38a06b53f4 | ||
|
|
be094fb07e | ||
|
|
cb9ec52a7d | ||
|
|
2755b250e4 | ||
|
|
1b7010bff8 | ||
|
|
f9936bf6ae | ||
|
|
5635e2be32 | ||
|
|
7374440621 | ||
|
|
60a22747ac | ||
|
|
9b295263f9 | ||
|
|
bc1d17d916 | ||
|
|
cba1bbfa28 | ||
|
|
8b190a6c23 | ||
|
|
e33966b3df | ||
|
|
3ad6b5a521 | ||
|
|
210dd41cee | ||
|
|
fa4e1d327a | ||
|
|
c19f92889e | ||
|
|
1f7d415868 | ||
|
|
ba5e27e011 | ||
|
|
161a61823f | ||
|
|
d1840f63e5 | ||
|
|
87fbb1a8c7 | ||
|
|
e8953d6072 | ||
|
|
dc30716b83 | ||
|
|
88841fd3f8 | ||
|
|
870f87cb17 | ||
|
|
0032d15c0a | ||
|
|
95be82cc84 | ||
|
|
d047035173 | ||
|
|
cc3e41ddc5 | ||
|
|
d4c0e4e26c | ||
|
|
fbc4e891bd | ||
|
|
cd450a05e5 |
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ]
|
||||
|
||||
@@ -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
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) =
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
+566
-456
File diff suppressed because it is too large
Load Diff
+738
-470
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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\& | ||||