Compare commits
13 Commits
v2.0-beta0
...
v2.0-rc2
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f3daa1de9 | |||
| 1ec664ad24 | |||
| 33698bd182 | |||
| 6b49793fbb | |||
| a8386d6c97 | |||
| b1ca48c2c5 | |||
| 3189681021 | |||
| ff9c08842b | |||
| e103738d39 | |||
| d854178255 | |||
| 0a32181e65 | |||
| 81fe03b8f3 | |||
| 4514c4864d |
19
build.fsx
19
build.fsx
@@ -34,9 +34,9 @@ let version =
|
|||||||
let zipTheme (name : string) (_ : TargetParameter) =
|
let zipTheme (name : string) (_ : TargetParameter) =
|
||||||
let path = $"src/{name}-theme"
|
let path = $"src/{name}-theme"
|
||||||
!! $"{path}/**/*"
|
!! $"{path}/**/*"
|
||||||
|> Zip.filesAsSpecs path //$"src/{name}-theme"
|
|> Zip.filesAsSpecs path
|
||||||
|> Seq.filter (fun (_, name) -> not (name.EndsWith ".zip"))
|
|> Seq.filter (fun (_, name) -> not (name.EndsWith ".zip"))
|
||||||
|> Zip.zipSpec $"{releasePath}/{name}.zip"
|
|> Zip.zipSpec $"{releasePath}/{name}-theme.zip"
|
||||||
|
|
||||||
/// Publish the project for the given runtime ID
|
/// Publish the project for the given runtime ID
|
||||||
let publishFor rid (_ : TargetParameter) =
|
let publishFor rid (_ : TargetParameter) =
|
||||||
@@ -45,11 +45,14 @@ let publishFor rid (_ : TargetParameter) =
|
|||||||
/// Package published output for the given runtime ID
|
/// Package published output for the given runtime ID
|
||||||
let packageFor (rid : string) (_ : TargetParameter) =
|
let packageFor (rid : string) (_ : TargetParameter) =
|
||||||
let path = $"{projectPath}/bin/Release/net6.0/{rid}/publish"
|
let path = $"{projectPath}/bin/Release/net6.0/{rid}/publish"
|
||||||
|
let prodSettings = $"{path}/appsettings.Production.json"
|
||||||
|
if File.exists prodSettings then File.delete prodSettings
|
||||||
[ !! $"{path}/**/*"
|
[ !! $"{path}/**/*"
|
||||||
|> Zip.filesAsSpecs path
|
|> Zip.filesAsSpecs path
|
||||||
|> Zip.moveToFolder "app"
|
|> Seq.map (fun (orig, dest) ->
|
||||||
Seq.singleton ($"{releasePath}/admin.zip", "admin.zip")
|
orig, if dest.StartsWith "MyWebLog" then dest.Replace ("MyWebLog", "myWebLog") else dest)
|
||||||
Seq.singleton ($"{releasePath}/default.zip", "default.zip")
|
Seq.singleton ($"{releasePath}/admin-theme.zip", "admin-theme.zip")
|
||||||
|
Seq.singleton ($"{releasePath}/default-theme.zip", "default-theme.zip")
|
||||||
]
|
]
|
||||||
|> Seq.concat
|
|> Seq.concat
|
||||||
|> Zip.zipSpec $"{releasePath}/myWebLog-{version}.{rid}.zip"
|
|> Zip.zipSpec $"{releasePath}/myWebLog-{version}.{rid}.zip"
|
||||||
@@ -86,7 +89,7 @@ Target.create "RepackageLinux" (fun _ ->
|
|||||||
Shell.mkdir workDir
|
Shell.mkdir workDir
|
||||||
Zip.unzip workDir zipArchive
|
Zip.unzip workDir zipArchive
|
||||||
Shell.cd workDir
|
Shell.cd workDir
|
||||||
sh "chmod" [ "+x"; "app/MyWebLog" ]
|
sh "chmod" [ "+x"; "./myWebLog" ]
|
||||||
sh "tar" [ "cfj"; $"../myWebLog-{version}.linux-x64.tar.bz2"; "." ]
|
sh "tar" [ "cfj"; $"../myWebLog-{version}.linux-x64.tar.bz2"; "." ]
|
||||||
Shell.cd "../.."
|
Shell.cd "../.."
|
||||||
Shell.rm zipArchive
|
Shell.rm zipArchive
|
||||||
@@ -96,8 +99,8 @@ Target.create "RepackageLinux" (fun _ ->
|
|||||||
Target.create "All" ignore
|
Target.create "All" ignore
|
||||||
|
|
||||||
Target.create "RemoveThemeArchives" (fun _ ->
|
Target.create "RemoveThemeArchives" (fun _ ->
|
||||||
Shell.rm $"{releasePath}/admin.zip"
|
Shell.rm $"{releasePath}/admin-theme.zip"
|
||||||
Shell.rm $"{releasePath}/default.zip"
|
Shell.rm $"{releasePath}/default-theme.zip"
|
||||||
)
|
)
|
||||||
|
|
||||||
Target.create "CI" ignore
|
Target.create "CI" ignore
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
|
|
||||||
// Category
|
|
||||||
r.db('myWebLog').table('Category').map({
|
|
||||||
Description: r.row('description'),
|
|
||||||
Id: r.row('id'),
|
|
||||||
Name: r.row('name'),
|
|
||||||
ParentId: r.row('parentId'),
|
|
||||||
Slug: r.row('slug'),
|
|
||||||
WebLogId: r.row('webLogId')
|
|
||||||
})
|
|
||||||
|
|
||||||
// Page
|
|
||||||
r.db('myWebLog').table('Page').map({
|
|
||||||
AuthorId: r.row('authorId'),
|
|
||||||
Id: r.row('id'),
|
|
||||||
Metadata: r.row('metadata').map(function (meta) {
|
|
||||||
return { Name: meta('name'), Value: meta('value') }
|
|
||||||
}),
|
|
||||||
Permalink: r.row('permalink'),
|
|
||||||
PriorPermalinks: r.row('priorPermalinks'),
|
|
||||||
PublishedOn: r.row('publishedOn'),
|
|
||||||
Revisions: r.row('revisions').map(function (rev) {
|
|
||||||
return {
|
|
||||||
AsOf: rev('asOf'),
|
|
||||||
Text: rev('text')
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
IsInPageList: r.row('showInPageList'),
|
|
||||||
Template: r.row('template'),
|
|
||||||
Text: r.row('text'),
|
|
||||||
Title: r.row('title'),
|
|
||||||
UpdatedOn: r.row('updatedOn'),
|
|
||||||
WebLogId: r.row('webLogId')
|
|
||||||
})
|
|
||||||
|
|
||||||
// Post
|
|
||||||
r.db('myWebLog').table('Post').map({
|
|
||||||
AuthorId: r.row('authorId'),
|
|
||||||
CategoryIds: r.row('categoryIds'),
|
|
||||||
Episode: r.branch(r.row.hasFields('episode'), {
|
|
||||||
Duration: r.row('episode')('duration'),
|
|
||||||
Length: r.row('episode')('length'),
|
|
||||||
Media: r.row('episode')('media'),
|
|
||||||
MediaType: r.row('episode')('mediaType').default(null),
|
|
||||||
ImageUrl: r.row('episode')('imageUrl').default(null),
|
|
||||||
Subtitle: r.row('episode')('subtitle').default(null),
|
|
||||||
Explicit: r.row('episode')('explicit').default(null),
|
|
||||||
ChapterFile: r.row('episode')('chapterFile').default(null),
|
|
||||||
ChapterType: r.row('episode')('chapterType').default(null),
|
|
||||||
TranscriptUrl: r.row('episode')('transcriptUrl').default(null),
|
|
||||||
TranscriptType: r.row('episode')('transcriptType').default(null),
|
|
||||||
TranscriptLang: r.row('episode')('transcriptLang').default(null),
|
|
||||||
TranscriptCaptions: r.row('episode')('transcriptCaptions').default(null),
|
|
||||||
SeasonNumber: r.row('episode')('seasonNumber').default(null),
|
|
||||||
SeasonDescription: r.row('episode')('seasonDescription').default(null),
|
|
||||||
EpisodeNumber: r.row('episode')('episodeNumber').default(null),
|
|
||||||
EpisodeDescription: r.row('episode')('episodeDescription').default(null)
|
|
||||||
}, null),
|
|
||||||
Id: r.row('id'),
|
|
||||||
Metadata: r.row('metadata').map(function (meta) {
|
|
||||||
return { Name: meta('name'), Value: meta('value') }
|
|
||||||
}),
|
|
||||||
Permalink: r.row('permalink'),
|
|
||||||
PriorPermalinks: r.row('priorPermalinks'),
|
|
||||||
PublishedOn: r.row('publishedOn'),
|
|
||||||
Revisions: r.row('revisions').map(function (rev) {
|
|
||||||
return {
|
|
||||||
AsOf: rev('asOf'),
|
|
||||||
Text: rev('text')
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
Status: r.row('status'),
|
|
||||||
Tags: r.row('tags'),
|
|
||||||
Template: r.row('template').default(null),
|
|
||||||
Text: r.row('text'),
|
|
||||||
Title: r.row('title'),
|
|
||||||
UpdatedOn: r.row('updatedOn'),
|
|
||||||
WebLogId: r.row('webLogId')
|
|
||||||
})
|
|
||||||
|
|
||||||
// TagMap
|
|
||||||
r.db('myWebLog').table('TagMap').map({
|
|
||||||
Id: r.row('id'),
|
|
||||||
Tag: r.row('tag'),
|
|
||||||
UrlValue: r.row('urlValue'),
|
|
||||||
WebLogId: r.row('webLogId')
|
|
||||||
})
|
|
||||||
|
|
||||||
// Theme
|
|
||||||
r.db('myWebLog').table('Theme').map({
|
|
||||||
Id: r.row('id'),
|
|
||||||
Name: r.row('name'),
|
|
||||||
Templates: r.row('templates').map(function (tmpl) {
|
|
||||||
return {
|
|
||||||
Name: tmpl('name'),
|
|
||||||
Text: tmpl('text')
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
Version: r.row('version')
|
|
||||||
})
|
|
||||||
|
|
||||||
// ThemeAsset
|
|
||||||
r.db('myWebLog').table('ThemeAsset').map({
|
|
||||||
Data: r.row('data'),
|
|
||||||
Id: r.row('id'),
|
|
||||||
UpdatedOn: r.row('updatedOn')
|
|
||||||
})
|
|
||||||
|
|
||||||
// WebLog
|
|
||||||
r.db('myWebLog').table('WebLog').map(
|
|
||||||
{ AutoHtmx: r.row('autoHtmx'),
|
|
||||||
DefaultPage: r.row('defaultPage'),
|
|
||||||
Id: r.row('id'),
|
|
||||||
Name: r.row('name'),
|
|
||||||
PostsPerPage: r.row('postsPerPage'),
|
|
||||||
Rss: {
|
|
||||||
IsCategoryEnabled: r.row('rss')('categoryEnabled'),
|
|
||||||
Copyright: r.row('rss')('copyright'),
|
|
||||||
CustomFeeds: r.row('rss')('customFeeds').map(function (feed) {
|
|
||||||
return {
|
|
||||||
Id: feed('id'),
|
|
||||||
Path: feed('path'),
|
|
||||||
Podcast: {
|
|
||||||
DefaultMediaType: feed('podcast')('defaultMediaType'),
|
|
||||||
DisplayedAuthor: feed('podcast')('displayedAuthor'),
|
|
||||||
Email: feed('podcast')('email'),
|
|
||||||
Explicit: feed('podcast')('explicit'),
|
|
||||||
FundingText: feed('podcast')('fundingText'),
|
|
||||||
FundingUrl: feed('podcast')('fundingUrl'),
|
|
||||||
PodcastGuid: feed('podcast')('guid'),
|
|
||||||
AppleCategory: feed('podcast')('iTunesCategory'),
|
|
||||||
AppleSubcategory: feed('podcast')('iTunesSubcategory'),
|
|
||||||
ImageUrl: feed('podcast')('imageUrl'),
|
|
||||||
ItemsInFeed: feed('podcast')('itemsInFeed'),
|
|
||||||
MediaBaseUrl: feed('podcast')('mediaBaseUrl'),
|
|
||||||
Medium: feed('podcast')('medium'),
|
|
||||||
Subtitle: feed('podcast')('subtitle'),
|
|
||||||
Summary: feed('podcast')('summary'),
|
|
||||||
Title: feed('podcast')('title')
|
|
||||||
},
|
|
||||||
Source: feed('source')
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
IsFeedEnabled: r.row('rss')('feedEnabled'),
|
|
||||||
FeedName: r.row('rss')('feedName'),
|
|
||||||
ItemsInFeed: r.row('rss')('itemsInFeed'),
|
|
||||||
IsTagEnabled: r.row('rss')('tagEnabled')
|
|
||||||
},
|
|
||||||
Slug: r.row('slug'),
|
|
||||||
Subtitle: r.row('subtitle'),
|
|
||||||
ThemeId: r.row('themePath'),
|
|
||||||
TimeZone: r.row('timeZone'),
|
|
||||||
Uploads: r.row('uploads'),
|
|
||||||
UrlBase: r.row('urlBase')
|
|
||||||
})
|
|
||||||
|
|
||||||
// WebLogUser
|
|
||||||
r.db('myWebLog').table('WebLogUser').map({
|
|
||||||
AccessLevel: r.row('authorizationLevel'),
|
|
||||||
FirstName: r.row('firstName'),
|
|
||||||
Id: r.row('id'),
|
|
||||||
LastName: r.row('lastName'),
|
|
||||||
PasswordHash: r.row('passwordHash'),
|
|
||||||
PreferredName: r.row('preferredName'),
|
|
||||||
Salt: r.row('salt'),
|
|
||||||
Url: r.row('url'),
|
|
||||||
Email: r.row('userName'),
|
|
||||||
WebLogId: r.row('webLogId'),
|
|
||||||
CreatedOn: r.branch(r.row.hasFields('createdOn'), r.row('createdOn'), r.expr(new Date(0))),
|
|
||||||
LastSeenOn: r.row('lastSeenOn').default(null)
|
|
||||||
})
|
|
||||||
10
src/Directory.Build.props
Normal file
10
src/Directory.Build.props
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
<AssemblyVersion>2.0.0.0</AssemblyVersion>
|
||||||
|
<FileVersion>2.0.0.0</FileVersion>
|
||||||
|
<Version>2.0.0</Version>
|
||||||
|
<VersionSuffix>rc2</VersionSuffix>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -122,12 +122,13 @@ module Json =
|
|||||||
(string >> WebLogUserId) reader.Value
|
(string >> WebLogUserId) reader.Value
|
||||||
|
|
||||||
open Microsoft.FSharpLu.Json
|
open Microsoft.FSharpLu.Json
|
||||||
|
open NodaTime
|
||||||
/// All converters to use for data conversion
|
open NodaTime.Serialization.JsonNet
|
||||||
let all () : JsonConverter seq =
|
|
||||||
seq {
|
/// Configure a serializer to use these converters
|
||||||
// Our converters
|
let configure (ser : JsonSerializer) =
|
||||||
CategoryIdConverter ()
|
// Our converters
|
||||||
|
[ CategoryIdConverter () :> JsonConverter
|
||||||
CommentIdConverter ()
|
CommentIdConverter ()
|
||||||
CustomFeedIdConverter ()
|
CustomFeedIdConverter ()
|
||||||
CustomFeedSourceConverter ()
|
CustomFeedSourceConverter ()
|
||||||
@@ -143,6 +144,35 @@ module Json =
|
|||||||
UploadIdConverter ()
|
UploadIdConverter ()
|
||||||
WebLogIdConverter ()
|
WebLogIdConverter ()
|
||||||
WebLogUserIdConverter ()
|
WebLogUserIdConverter ()
|
||||||
// Handles DUs with no associated data, as well as option fields
|
] |> List.iter ser.Converters.Add
|
||||||
CompactUnionJsonConverter ()
|
// NodaTime
|
||||||
}
|
let _ = ser.ConfigureForNodaTime DateTimeZoneProviders.Tzdb
|
||||||
|
// Handles DUs with no associated data, as well as option fields
|
||||||
|
ser.Converters.Add (CompactUnionJsonConverter ())
|
||||||
|
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) =
|
||||||
|
if Option.isNone serializerSettings then
|
||||||
|
serializerSettings <- JsonSerializerSettings (
|
||||||
|
ConstructorHandling = ser.ConstructorHandling,
|
||||||
|
ContractResolver = ser.ContractResolver,
|
||||||
|
Converters = ser.Converters,
|
||||||
|
DefaultValueHandling = ser.DefaultValueHandling,
|
||||||
|
DateFormatHandling = ser.DateFormatHandling,
|
||||||
|
MetadataPropertyHandling = ser.MetadataPropertyHandling,
|
||||||
|
MissingMemberHandling = ser.MissingMemberHandling,
|
||||||
|
NullValueHandling = ser.NullValueHandling,
|
||||||
|
ObjectCreationHandling = ser.ObjectCreationHandling,
|
||||||
|
ReferenceLoopHandling = ser.ReferenceLoopHandling,
|
||||||
|
SerializationBinder = ser.SerializationBinder,
|
||||||
|
TraceWriter = ser.TraceWriter,
|
||||||
|
TypeNameAssemblyFormatHandling = ser.TypeNameAssemblyFormatHandling,
|
||||||
|
TypeNameHandling = ser.TypeNameHandling)
|
||||||
|
|> Some
|
||||||
|
serializerSettings.Value
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
namespace MyWebLog.Data
|
namespace MyWebLog.Data
|
||||||
|
|
||||||
open System
|
|
||||||
open System.Threading.Tasks
|
open System.Threading.Tasks
|
||||||
open MyWebLog
|
open MyWebLog
|
||||||
open MyWebLog.ViewModels
|
open MyWebLog.ViewModels
|
||||||
|
open Newtonsoft.Json
|
||||||
|
open NodaTime
|
||||||
|
|
||||||
|
/// The result of a category deletion attempt
|
||||||
|
type CategoryDeleteResult =
|
||||||
|
/// The category was deleted successfully
|
||||||
|
| CategoryDeleted
|
||||||
|
/// The category was deleted successfully, and its children were reassigned to its parent
|
||||||
|
| ReassignedChildCategories
|
||||||
|
/// The category was not found, so no effort was made to delete it
|
||||||
|
| CategoryNotFound
|
||||||
|
|
||||||
|
|
||||||
/// Data functions to support manipulating categories
|
/// Data functions to support manipulating categories
|
||||||
type ICategoryData =
|
type ICategoryData =
|
||||||
@@ -18,7 +29,7 @@ type ICategoryData =
|
|||||||
abstract member CountTopLevel : WebLogId -> Task<int>
|
abstract member CountTopLevel : WebLogId -> Task<int>
|
||||||
|
|
||||||
/// Delete a category (also removes it from posts)
|
/// Delete a category (also removes it from posts)
|
||||||
abstract member Delete : CategoryId -> WebLogId -> Task<bool>
|
abstract member Delete : CategoryId -> WebLogId -> Task<CategoryDeleteResult>
|
||||||
|
|
||||||
/// Find all categories for a web log, sorted alphabetically and grouped by hierarchy
|
/// Find all categories for a web log, sorted alphabetically and grouped by hierarchy
|
||||||
abstract member FindAllForView : WebLogId -> Task<DisplayCategory[]>
|
abstract member FindAllForView : WebLogId -> Task<DisplayCategory[]>
|
||||||
@@ -127,7 +138,7 @@ type IPostData =
|
|||||||
WebLogId -> tag : string -> pageNbr : int -> postsPerPage : int -> Task<Post list>
|
WebLogId -> tag : string -> pageNbr : int -> postsPerPage : int -> Task<Post list>
|
||||||
|
|
||||||
/// Find the next older and newer post for the given published date/time (excluding revisions and prior permalinks)
|
/// Find the next older and newer post for the given published date/time (excluding revisions and prior permalinks)
|
||||||
abstract member FindSurroundingPosts : WebLogId -> publishedOn : DateTime -> Task<Post option * Post option>
|
abstract member FindSurroundingPosts : WebLogId -> publishedOn : Instant -> Task<Post option * Post option>
|
||||||
|
|
||||||
/// Restore posts from a backup
|
/// Restore posts from a backup
|
||||||
abstract member Restore : Post list -> Task<unit>
|
abstract member Restore : Post list -> Task<unit>
|
||||||
@@ -167,9 +178,15 @@ type ITagMapData =
|
|||||||
/// Functions to manipulate themes
|
/// Functions to manipulate themes
|
||||||
type IThemeData =
|
type IThemeData =
|
||||||
|
|
||||||
/// Retrieve all themes (except "admin")
|
/// Retrieve all themes (except "admin") (excluding the text of templates)
|
||||||
abstract member All : unit -> Task<Theme list>
|
abstract member All : unit -> Task<Theme list>
|
||||||
|
|
||||||
|
/// Delete a theme
|
||||||
|
abstract member Delete : ThemeId -> Task<bool>
|
||||||
|
|
||||||
|
/// Determine if a theme exists
|
||||||
|
abstract member Exists : ThemeId -> Task<bool>
|
||||||
|
|
||||||
/// Find a theme by its ID
|
/// Find a theme by its ID
|
||||||
abstract member FindById : ThemeId -> Task<Theme option>
|
abstract member FindById : ThemeId -> Task<Theme option>
|
||||||
|
|
||||||
@@ -310,6 +327,9 @@ type IData =
|
|||||||
/// Web log user data functions
|
/// Web log user data functions
|
||||||
abstract member WebLogUser : IWebLogUserData
|
abstract member WebLogUser : IWebLogUserData
|
||||||
|
|
||||||
|
/// A JSON serializer for use in persistence
|
||||||
|
abstract member Serializer : JsonSerializer
|
||||||
|
|
||||||
/// Do any required start up data checks
|
/// Do any required start up data checks
|
||||||
abstract member StartUp : unit -> Task<unit>
|
abstract member StartUp : unit -> Task<unit>
|
||||||
|
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
|
||||||
<DebugType>embedded</DebugType>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\MyWebLog.Domain\MyWebLog.Domain.fsproj" />
|
<ProjectReference Include="..\MyWebLog.Domain\MyWebLog.Domain.fsproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="6.0.7" />
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="6.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" />
|
||||||
<PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.7" />
|
<PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.7" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||||
|
<PackageReference Include="NodaTime" Version="3.1.2" />
|
||||||
|
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" />
|
||||||
|
<PackageReference Include="Npgsql" Version="6.0.6" />
|
||||||
|
<PackageReference Include="Npgsql.FSharp" Version="5.3.0" />
|
||||||
|
<PackageReference Include="Npgsql.NodaTime" Version="6.0.6" />
|
||||||
<PackageReference Include="RethinkDb.Driver" Version="2.3.150" />
|
<PackageReference Include="RethinkDb.Driver" Version="2.3.150" />
|
||||||
<PackageReference Include="RethinkDb.Driver.FSharp" Version="0.9.0-beta-07" />
|
<PackageReference Include="RethinkDb.Driver.FSharp" Version="0.9.0-beta-07" />
|
||||||
<PackageReference Update="FSharp.Core" Version="6.0.5" />
|
<PackageReference Update="FSharp.Core" Version="6.0.5" />
|
||||||
@@ -35,6 +35,17 @@
|
|||||||
<Compile Include="SQLite\SQLiteWebLogData.fs" />
|
<Compile Include="SQLite\SQLiteWebLogData.fs" />
|
||||||
<Compile Include="SQLite\SQLiteWebLogUserData.fs" />
|
<Compile Include="SQLite\SQLiteWebLogUserData.fs" />
|
||||||
<Compile Include="SQLiteData.fs" />
|
<Compile Include="SQLiteData.fs" />
|
||||||
|
<Compile Include="Postgres\PostgresHelpers.fs" />
|
||||||
|
<Compile Include="Postgres\PostgresCache.fs" />
|
||||||
|
<Compile Include="Postgres\PostgresCategoryData.fs" />
|
||||||
|
<Compile Include="Postgres\PostgresPageData.fs" />
|
||||||
|
<Compile Include="Postgres\PostgresPostData.fs" />
|
||||||
|
<Compile Include="Postgres\PostgresTagMapData.fs" />
|
||||||
|
<Compile Include="Postgres\PostgresThemeData.fs" />
|
||||||
|
<Compile Include="Postgres\PostgresUploadData.fs" />
|
||||||
|
<Compile Include="Postgres\PostgresWebLogData.fs" />
|
||||||
|
<Compile Include="Postgres\PostgresWebLogUserData.fs" />
|
||||||
|
<Compile Include="PostgresData.fs" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
210
src/MyWebLog.Data/Postgres/PostgresCache.fs
Normal file
210
src/MyWebLog.Data/Postgres/PostgresCache.fs
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
namespace MyWebLog.Data.Postgres
|
||||||
|
|
||||||
|
open System.Threading
|
||||||
|
open System.Threading.Tasks
|
||||||
|
open Microsoft.Extensions.Caching.Distributed
|
||||||
|
open NodaTime
|
||||||
|
open Npgsql.FSharp
|
||||||
|
|
||||||
|
/// Helper types and functions for the cache
|
||||||
|
[<AutoOpen>]
|
||||||
|
module private Helpers =
|
||||||
|
|
||||||
|
/// The cache entry
|
||||||
|
type Entry =
|
||||||
|
{ /// The ID of the cache entry
|
||||||
|
Id : string
|
||||||
|
|
||||||
|
/// The value to be cached
|
||||||
|
Payload : byte[]
|
||||||
|
|
||||||
|
/// When this entry will expire
|
||||||
|
ExpireAt : Instant
|
||||||
|
|
||||||
|
/// The duration by which the expiration should be pushed out when being refreshed
|
||||||
|
SlidingExpiration : Duration option
|
||||||
|
|
||||||
|
/// The must-expire-by date/time for the cache entry
|
||||||
|
AbsoluteExpiration : Instant option
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a task synchronously
|
||||||
|
let sync<'T> (it : Task<'T>) = it |> (Async.AwaitTask >> Async.RunSynchronously)
|
||||||
|
|
||||||
|
/// Get the current instant
|
||||||
|
let getNow () = SystemClock.Instance.GetCurrentInstant ()
|
||||||
|
|
||||||
|
/// Create a parameter for the expire-at time
|
||||||
|
let expireParam =
|
||||||
|
typedParam "expireAt"
|
||||||
|
|
||||||
|
|
||||||
|
/// A distributed cache implementation in PostgreSQL used to handle sessions for myWebLog
|
||||||
|
type DistributedCache (connStr : string) =
|
||||||
|
|
||||||
|
// ~~~ INITIALIZATION ~~~
|
||||||
|
|
||||||
|
do
|
||||||
|
task {
|
||||||
|
let! exists =
|
||||||
|
Sql.connect connStr
|
||||||
|
|> Sql.query $"
|
||||||
|
SELECT EXISTS
|
||||||
|
(SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'session')
|
||||||
|
AS {existsName}"
|
||||||
|
|> Sql.executeRowAsync Map.toExists
|
||||||
|
if not exists then
|
||||||
|
let! _ =
|
||||||
|
Sql.connect connStr
|
||||||
|
|> Sql.query
|
||||||
|
"CREATE TABLE session (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
payload BYTEA NOT NULL,
|
||||||
|
expire_at TIMESTAMPTZ NOT NULL,
|
||||||
|
sliding_expiration INTERVAL,
|
||||||
|
absolute_expiration TIMESTAMPTZ);
|
||||||
|
CREATE INDEX idx_session_expiration ON session (expire_at)"
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
()
|
||||||
|
} |> sync
|
||||||
|
|
||||||
|
// ~~~ SUPPORT FUNCTIONS ~~~
|
||||||
|
|
||||||
|
/// Get an entry, updating it for sliding expiration
|
||||||
|
let getEntry key = backgroundTask {
|
||||||
|
let idParam = "@id", Sql.string key
|
||||||
|
let! tryEntry =
|
||||||
|
Sql.connect connStr
|
||||||
|
|> Sql.query "SELECT * FROM session WHERE id = @id"
|
||||||
|
|> Sql.parameters [ idParam ]
|
||||||
|
|> Sql.executeAsync (fun row ->
|
||||||
|
{ Id = row.string "id"
|
||||||
|
Payload = row.bytea "payload"
|
||||||
|
ExpireAt = row.fieldValue<Instant> "expire_at"
|
||||||
|
SlidingExpiration = row.fieldValueOrNone<Duration> "sliding_expiration"
|
||||||
|
AbsoluteExpiration = row.fieldValueOrNone<Instant> "absolute_expiration" })
|
||||||
|
|> tryHead
|
||||||
|
match tryEntry with
|
||||||
|
| Some entry ->
|
||||||
|
let now = getNow ()
|
||||||
|
let slideExp = defaultArg entry.SlidingExpiration Duration.MinValue
|
||||||
|
let absExp = defaultArg entry.AbsoluteExpiration Instant.MinValue
|
||||||
|
let needsRefresh, item =
|
||||||
|
if entry.ExpireAt = absExp then false, entry
|
||||||
|
elif slideExp = Duration.MinValue && absExp = Instant.MinValue then false, entry
|
||||||
|
elif absExp > Instant.MinValue && entry.ExpireAt.Plus slideExp > absExp then
|
||||||
|
true, { entry with ExpireAt = absExp }
|
||||||
|
else true, { entry with ExpireAt = now.Plus slideExp }
|
||||||
|
if needsRefresh then
|
||||||
|
let! _ =
|
||||||
|
Sql.connect connStr
|
||||||
|
|> Sql.query "UPDATE session SET expire_at = @expireAt WHERE id = @id"
|
||||||
|
|> Sql.parameters [ expireParam item.ExpireAt; idParam ]
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
()
|
||||||
|
return if item.ExpireAt > now then Some entry else None
|
||||||
|
| None -> return None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last time expired entries were purged (runs every 30 minutes)
|
||||||
|
let mutable lastPurge = Instant.MinValue
|
||||||
|
|
||||||
|
/// Purge expired entries every 30 minutes
|
||||||
|
let purge () = backgroundTask {
|
||||||
|
let now = getNow ()
|
||||||
|
if lastPurge.Plus (Duration.FromMinutes 30L) < now then
|
||||||
|
let! _ =
|
||||||
|
Sql.connect connStr
|
||||||
|
|> Sql.query "DELETE FROM session WHERE expire_at < @expireAt"
|
||||||
|
|> Sql.parameters [ expireParam now ]
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
lastPurge <- now
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a cache entry
|
||||||
|
let removeEntry key = backgroundTask {
|
||||||
|
let! _ =
|
||||||
|
Sql.connect connStr
|
||||||
|
|> Sql.query "DELETE FROM session WHERE id = @id"
|
||||||
|
|> Sql.parameters [ "@id", Sql.string key ]
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save an entry
|
||||||
|
let saveEntry (opts : DistributedCacheEntryOptions) key payload = backgroundTask {
|
||||||
|
let now = getNow ()
|
||||||
|
let expireAt, slideExp, absExp =
|
||||||
|
if opts.SlidingExpiration.HasValue then
|
||||||
|
let slide = Duration.FromTimeSpan opts.SlidingExpiration.Value
|
||||||
|
now.Plus slide, Some slide, None
|
||||||
|
elif opts.AbsoluteExpiration.HasValue then
|
||||||
|
let exp = Instant.FromDateTimeOffset opts.AbsoluteExpiration.Value
|
||||||
|
exp, None, Some exp
|
||||||
|
elif opts.AbsoluteExpirationRelativeToNow.HasValue then
|
||||||
|
let exp = now.Plus (Duration.FromTimeSpan opts.AbsoluteExpirationRelativeToNow.Value)
|
||||||
|
exp, None, Some exp
|
||||||
|
else
|
||||||
|
// Default to 1 hour sliding expiration
|
||||||
|
let slide = Duration.FromHours 1
|
||||||
|
now.Plus slide, Some slide, None
|
||||||
|
let! _ =
|
||||||
|
Sql.connect connStr
|
||||||
|
|> Sql.query
|
||||||
|
"INSERT INTO session (
|
||||||
|
id, payload, expire_at, sliding_expiration, absolute_expiration
|
||||||
|
) VALUES (
|
||||||
|
@id, @payload, @expireAt, @slideExp, @absExp
|
||||||
|
) ON CONFLICT (id) DO UPDATE
|
||||||
|
SET payload = EXCLUDED.payload,
|
||||||
|
expire_at = EXCLUDED.expire_at,
|
||||||
|
sliding_expiration = EXCLUDED.sliding_expiration,
|
||||||
|
absolute_expiration = EXCLUDED.absolute_expiration"
|
||||||
|
|> Sql.parameters
|
||||||
|
[ "@id", Sql.string key
|
||||||
|
"@payload", Sql.bytea payload
|
||||||
|
expireParam expireAt
|
||||||
|
optParam "slideExp" slideExp
|
||||||
|
optParam "absExp" absExp ]
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ~~~ IMPLEMENTATION FUNCTIONS ~~~
|
||||||
|
|
||||||
|
/// Retrieve the data for a cache entry
|
||||||
|
let get key (_ : CancellationToken) = backgroundTask {
|
||||||
|
match! getEntry key with
|
||||||
|
| Some entry ->
|
||||||
|
do! purge ()
|
||||||
|
return entry.Payload
|
||||||
|
| None -> return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh an entry
|
||||||
|
let refresh key (cancelToken : CancellationToken) = backgroundTask {
|
||||||
|
let! _ = get key cancelToken
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove an entry
|
||||||
|
let remove key (_ : CancellationToken) = backgroundTask {
|
||||||
|
do! removeEntry key
|
||||||
|
do! purge ()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set an entry
|
||||||
|
let set key value options (_ : CancellationToken) = backgroundTask {
|
||||||
|
do! saveEntry options key value
|
||||||
|
do! purge ()
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IDistributedCache with
|
||||||
|
member this.Get key = get key CancellationToken.None |> sync
|
||||||
|
member this.GetAsync (key, token) = get key token
|
||||||
|
member this.Refresh key = refresh key CancellationToken.None |> sync
|
||||||
|
member this.RefreshAsync (key, token) = refresh key token
|
||||||
|
member this.Remove key = remove key CancellationToken.None |> sync
|
||||||
|
member this.RemoveAsync (key, token) = remove key token
|
||||||
|
member this.Set (key, value, options) = set key value options CancellationToken.None |> sync
|
||||||
|
member this.SetAsync (key, value, options, token) = set key value options token
|
||||||
172
src/MyWebLog.Data/Postgres/PostgresCategoryData.fs
Normal file
172
src/MyWebLog.Data/Postgres/PostgresCategoryData.fs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
namespace MyWebLog.Data.Postgres
|
||||||
|
|
||||||
|
open MyWebLog
|
||||||
|
open MyWebLog.Data
|
||||||
|
open Npgsql
|
||||||
|
open Npgsql.FSharp
|
||||||
|
|
||||||
|
/// PostgreSQL myWebLog category data implementation
|
||||||
|
type PostgresCategoryData (conn : NpgsqlConnection) =
|
||||||
|
|
||||||
|
/// Count all categories for the given web log
|
||||||
|
let countAll webLogId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"SELECT COUNT(id) AS {countName} FROM category WHERE web_log_id = @webLogId"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeRowAsync Map.toCount
|
||||||
|
|
||||||
|
/// Count all top-level categories for the given web log
|
||||||
|
let countTopLevel webLogId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"SELECT COUNT(id) AS {countName} FROM category WHERE web_log_id = @webLogId AND parent_id IS NULL"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeRowAsync Map.toCount
|
||||||
|
|
||||||
|
/// Retrieve all categories for the given web log in a DotLiquid-friendly format
|
||||||
|
let findAllForView webLogId = backgroundTask {
|
||||||
|
let! cats =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM category WHERE web_log_id = @webLogId ORDER BY LOWER(name)"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeAsync Map.toCategory
|
||||||
|
let ordered = Utils.orderByHierarchy cats None None []
|
||||||
|
let counts =
|
||||||
|
ordered
|
||||||
|
|> Seq.map (fun it ->
|
||||||
|
// Parent category post counts include posts in subcategories
|
||||||
|
let catIdSql, catIdParams =
|
||||||
|
ordered
|
||||||
|
|> Seq.filter (fun cat -> cat.ParentNames |> Array.contains it.Name)
|
||||||
|
|> Seq.map (fun cat -> cat.Id)
|
||||||
|
|> Seq.append (Seq.singleton it.Id)
|
||||||
|
|> List.ofSeq
|
||||||
|
|> inClause "AND pc.category_id" "id" id
|
||||||
|
let postCount =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"
|
||||||
|
SELECT COUNT(DISTINCT p.id) AS {countName}
|
||||||
|
FROM post p
|
||||||
|
INNER JOIN post_category pc ON pc.post_id = p.id
|
||||||
|
WHERE p.web_log_id = @webLogId
|
||||||
|
AND p.status = 'Published'
|
||||||
|
{catIdSql}"
|
||||||
|
|> Sql.parameters (webLogIdParam webLogId :: catIdParams)
|
||||||
|
|> Sql.executeRowAsync Map.toCount
|
||||||
|
|> Async.AwaitTask
|
||||||
|
|> Async.RunSynchronously
|
||||||
|
it.Id, postCount)
|
||||||
|
|> List.ofSeq
|
||||||
|
return
|
||||||
|
ordered
|
||||||
|
|> Seq.map (fun cat ->
|
||||||
|
{ cat with
|
||||||
|
PostCount = counts
|
||||||
|
|> List.tryFind (fun c -> fst c = cat.Id)
|
||||||
|
|> Option.map snd
|
||||||
|
|> Option.defaultValue 0
|
||||||
|
})
|
||||||
|
|> Array.ofSeq
|
||||||
|
}
|
||||||
|
/// Find a category by its ID for the given web log
|
||||||
|
let findById catId webLogId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM category WHERE id = @id AND web_log_id = @webLogId"
|
||||||
|
|> Sql.parameters [ "@id", Sql.string (CategoryId.toString catId); webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeAsync Map.toCategory
|
||||||
|
|> tryHead
|
||||||
|
|
||||||
|
/// Find all categories for the given web log
|
||||||
|
let findByWebLog webLogId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM category WHERE web_log_id = @webLogId"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeAsync Map.toCategory
|
||||||
|
|
||||||
|
|
||||||
|
/// Delete a category
|
||||||
|
let delete catId webLogId = backgroundTask {
|
||||||
|
match! findById catId webLogId with
|
||||||
|
| Some cat ->
|
||||||
|
// Reassign any children to the category's parent category
|
||||||
|
let parentParam = "@parentId", Sql.string (CategoryId.toString catId)
|
||||||
|
let! hasChildren =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"SELECT EXISTS (SELECT 1 FROM category WHERE parent_id = @parentId) AS {existsName}"
|
||||||
|
|> Sql.parameters [ parentParam ]
|
||||||
|
|> Sql.executeRowAsync Map.toExists
|
||||||
|
if hasChildren then
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "UPDATE category SET parent_id = @newParentId WHERE parent_id = @parentId"
|
||||||
|
|> Sql.parameters
|
||||||
|
[ parentParam
|
||||||
|
"@newParentId", Sql.stringOrNone (cat.ParentId |> Option.map CategoryId.toString) ]
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
()
|
||||||
|
// Delete the category off all posts where it is assigned, and the category itself
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query
|
||||||
|
"DELETE FROM post_category
|
||||||
|
WHERE category_id = @id
|
||||||
|
AND post_id IN (SELECT id FROM post WHERE web_log_id = @webLogId);
|
||||||
|
DELETE FROM category WHERE id = @id"
|
||||||
|
|> Sql.parameters [ "@id", Sql.string (CategoryId.toString catId); webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
return if hasChildren then ReassignedChildCategories else CategoryDeleted
|
||||||
|
| None -> return CategoryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The INSERT statement for a category
|
||||||
|
let catInsert =
|
||||||
|
"INSERT INTO category (
|
||||||
|
id, web_log_id, name, slug, description, parent_id
|
||||||
|
) VALUES (
|
||||||
|
@id, @webLogId, @name, @slug, @description, @parentId
|
||||||
|
)"
|
||||||
|
|
||||||
|
/// Create parameters for a category insert / update
|
||||||
|
let catParameters (cat : Category) = [
|
||||||
|
webLogIdParam cat.WebLogId
|
||||||
|
"@id", Sql.string (CategoryId.toString cat.Id)
|
||||||
|
"@name", Sql.string cat.Name
|
||||||
|
"@slug", Sql.string cat.Slug
|
||||||
|
"@description", Sql.stringOrNone cat.Description
|
||||||
|
"@parentId", Sql.stringOrNone (cat.ParentId |> Option.map CategoryId.toString)
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Save a category
|
||||||
|
let save cat = backgroundTask {
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"
|
||||||
|
{catInsert} ON CONFLICT (id) DO UPDATE
|
||||||
|
SET name = EXCLUDED.name,
|
||||||
|
slug = EXCLUDED.slug,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
parent_id = EXCLUDED.parent_id"
|
||||||
|
|> Sql.parameters (catParameters cat)
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore categories from a backup
|
||||||
|
let restore cats = backgroundTask {
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.executeTransactionAsync [
|
||||||
|
catInsert, cats |> List.map catParameters
|
||||||
|
]
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ICategoryData with
|
||||||
|
member _.Add cat = save cat
|
||||||
|
member _.CountAll webLogId = countAll webLogId
|
||||||
|
member _.CountTopLevel webLogId = countTopLevel webLogId
|
||||||
|
member _.FindAllForView webLogId = findAllForView webLogId
|
||||||
|
member _.FindById catId webLogId = findById catId webLogId
|
||||||
|
member _.FindByWebLog webLogId = findByWebLog webLogId
|
||||||
|
member _.Delete catId webLogId = delete catId webLogId
|
||||||
|
member _.Restore cats = restore cats
|
||||||
|
member _.Update cat = save cat
|
||||||
240
src/MyWebLog.Data/Postgres/PostgresHelpers.fs
Normal file
240
src/MyWebLog.Data/Postgres/PostgresHelpers.fs
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
/// Helper functions for the PostgreSQL data implementation
|
||||||
|
[<AutoOpen>]
|
||||||
|
module MyWebLog.Data.Postgres.PostgresHelpers
|
||||||
|
|
||||||
|
open System
|
||||||
|
open System.Threading.Tasks
|
||||||
|
open MyWebLog
|
||||||
|
open MyWebLog.Data
|
||||||
|
open Newtonsoft.Json
|
||||||
|
open NodaTime
|
||||||
|
open Npgsql
|
||||||
|
open Npgsql.FSharp
|
||||||
|
|
||||||
|
/// Create a SQL parameter for the web log ID
|
||||||
|
let webLogIdParam webLogId =
|
||||||
|
"@webLogId", Sql.string (WebLogId.toString webLogId)
|
||||||
|
|
||||||
|
/// The name of the field to select to be able to use Map.toCount
|
||||||
|
let countName = "the_count"
|
||||||
|
|
||||||
|
/// The name of the field to select to be able to use Map.toExists
|
||||||
|
let existsName = "does_exist"
|
||||||
|
|
||||||
|
/// 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}", ($"@%s{paramName}{idx}", Sql.string (valueFunc it)) :: itemP)
|
||||||
|
(Seq.ofList items
|
||||||
|
|> Seq.map (fun it ->
|
||||||
|
$"%s{colNameAndPrefix} IN (@%s{paramName}0", [ $"@%s{paramName}0", Sql.string (valueFunc it) ])
|
||||||
|
|> Seq.head)
|
||||||
|
|> function sql, ps -> $"{sql})", ps
|
||||||
|
|
||||||
|
/// Create the SQL and parameters for the array equivalent of an IN clause
|
||||||
|
let arrayInClause<'T> name (valueFunc : 'T -> string) (items : 'T list) =
|
||||||
|
if List.isEmpty items then "TRUE = FALSE", []
|
||||||
|
else
|
||||||
|
let mutable idx = 0
|
||||||
|
items
|
||||||
|
|> List.skip 1
|
||||||
|
|> List.fold (fun (itemS, itemP) it ->
|
||||||
|
idx <- idx + 1
|
||||||
|
$"{itemS} OR %s{name} && ARRAY[@{name}{idx}]",
|
||||||
|
($"@{name}{idx}", Sql.string (valueFunc it)) :: itemP)
|
||||||
|
(Seq.ofList items
|
||||||
|
|> Seq.map (fun it ->
|
||||||
|
$"{name} && ARRAY[@{name}0]", [ $"@{name}0", Sql.string (valueFunc it) ])
|
||||||
|
|> Seq.head)
|
||||||
|
|
||||||
|
/// Get the first result of the given query
|
||||||
|
let tryHead<'T> (query : Task<'T list>) = backgroundTask {
|
||||||
|
let! results = query
|
||||||
|
return List.tryHead results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a parameter for a non-standard type
|
||||||
|
let typedParam<'T> name (it : 'T) =
|
||||||
|
$"@%s{name}", Sql.parameter (NpgsqlParameter ($"@{name}", it))
|
||||||
|
|
||||||
|
/// Create a parameter for a possibly-missing non-standard type
|
||||||
|
let optParam<'T> name (it : 'T option) =
|
||||||
|
let p = NpgsqlParameter ($"@%s{name}", if Option.isSome it then box it.Value else DBNull.Value)
|
||||||
|
p.ParameterName, Sql.parameter p
|
||||||
|
|
||||||
|
/// Mapping functions for SQL queries
|
||||||
|
module Map =
|
||||||
|
|
||||||
|
/// Map an id field to a category ID
|
||||||
|
let toCategoryId (row : RowReader) =
|
||||||
|
CategoryId (row.string "id")
|
||||||
|
|
||||||
|
/// Create a category from the current row
|
||||||
|
let toCategory (row : RowReader) : Category =
|
||||||
|
{ Id = toCategoryId row
|
||||||
|
WebLogId = row.string "web_log_id" |> WebLogId
|
||||||
|
Name = row.string "name"
|
||||||
|
Slug = row.string "slug"
|
||||||
|
Description = row.stringOrNone "description"
|
||||||
|
ParentId = row.stringOrNone "parent_id" |> Option.map CategoryId
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a count from a row
|
||||||
|
let toCount (row : RowReader) =
|
||||||
|
row.int countName
|
||||||
|
|
||||||
|
/// Create a custom feed from the current row
|
||||||
|
let toCustomFeed (ser : JsonSerializer) (row : RowReader) : CustomFeed =
|
||||||
|
{ Id = row.string "id" |> CustomFeedId
|
||||||
|
Source = row.string "source" |> CustomFeedSource.parse
|
||||||
|
Path = row.string "path" |> Permalink
|
||||||
|
Podcast = row.stringOrNone "podcast" |> Option.map (Utils.deserialize ser)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a true/false value as to whether an item exists
|
||||||
|
let toExists (row : RowReader) =
|
||||||
|
row.bool existsName
|
||||||
|
|
||||||
|
/// Create a meta item from the current row
|
||||||
|
let toMetaItem (row : RowReader) : MetaItem =
|
||||||
|
{ Name = row.string "name"
|
||||||
|
Value = row.string "value"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a permalink from the current row
|
||||||
|
let toPermalink (row : RowReader) =
|
||||||
|
Permalink (row.string "permalink")
|
||||||
|
|
||||||
|
/// Create a page from the current row
|
||||||
|
let toPage (ser : JsonSerializer) (row : RowReader) : Page =
|
||||||
|
{ Page.empty with
|
||||||
|
Id = row.string "id" |> PageId
|
||||||
|
WebLogId = row.string "web_log_id" |> WebLogId
|
||||||
|
AuthorId = row.string "author_id" |> WebLogUserId
|
||||||
|
Title = row.string "title"
|
||||||
|
Permalink = toPermalink row
|
||||||
|
PriorPermalinks = row.stringArray "prior_permalinks" |> Array.map Permalink |> List.ofArray
|
||||||
|
PublishedOn = row.fieldValue<Instant> "published_on"
|
||||||
|
UpdatedOn = row.fieldValue<Instant> "updated_on"
|
||||||
|
IsInPageList = row.bool "is_in_page_list"
|
||||||
|
Template = row.stringOrNone "template"
|
||||||
|
Text = row.string "page_text"
|
||||||
|
Metadata = row.stringOrNone "meta_items"
|
||||||
|
|> Option.map (Utils.deserialize ser)
|
||||||
|
|> Option.defaultValue []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a post from the current row
|
||||||
|
let toPost (ser : JsonSerializer) (row : RowReader) : Post =
|
||||||
|
{ Post.empty with
|
||||||
|
Id = row.string "id" |> PostId
|
||||||
|
WebLogId = row.string "web_log_id" |> WebLogId
|
||||||
|
AuthorId = row.string "author_id" |> WebLogUserId
|
||||||
|
Status = row.string "status" |> PostStatus.parse
|
||||||
|
Title = row.string "title"
|
||||||
|
Permalink = toPermalink row
|
||||||
|
PriorPermalinks = row.stringArray "prior_permalinks" |> Array.map Permalink |> List.ofArray
|
||||||
|
PublishedOn = row.fieldValueOrNone<Instant> "published_on"
|
||||||
|
UpdatedOn = row.fieldValue<Instant> "updated_on"
|
||||||
|
Template = row.stringOrNone "template"
|
||||||
|
Text = row.string "post_text"
|
||||||
|
Episode = row.stringOrNone "episode" |> Option.map (Utils.deserialize ser)
|
||||||
|
CategoryIds = row.stringArrayOrNone "category_ids"
|
||||||
|
|> Option.map (Array.map CategoryId >> List.ofArray)
|
||||||
|
|> Option.defaultValue []
|
||||||
|
Tags = row.stringArrayOrNone "tags"
|
||||||
|
|> Option.map List.ofArray
|
||||||
|
|> Option.defaultValue []
|
||||||
|
Metadata = row.stringOrNone "meta_items"
|
||||||
|
|> Option.map (Utils.deserialize ser)
|
||||||
|
|> Option.defaultValue []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a revision from the current row
|
||||||
|
let toRevision (row : RowReader) : Revision =
|
||||||
|
{ AsOf = row.fieldValue<Instant> "as_of"
|
||||||
|
Text = row.string "revision_text" |> MarkupText.parse
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a tag mapping from the current row
|
||||||
|
let toTagMap (row : RowReader) : TagMap =
|
||||||
|
{ Id = row.string "id" |> TagMapId
|
||||||
|
WebLogId = row.string "web_log_id" |> WebLogId
|
||||||
|
Tag = row.string "tag"
|
||||||
|
UrlValue = row.string "url_value"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a theme from the current row (excludes templates)
|
||||||
|
let toTheme (row : RowReader) : Theme =
|
||||||
|
{ Theme.empty with
|
||||||
|
Id = row.string "id" |> ThemeId
|
||||||
|
Name = row.string "name"
|
||||||
|
Version = row.string "version"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a theme asset from the current row
|
||||||
|
let toThemeAsset includeData (row : RowReader) : ThemeAsset =
|
||||||
|
{ Id = ThemeAssetId (ThemeId (row.string "theme_id"), row.string "path")
|
||||||
|
UpdatedOn = row.fieldValue<Instant> "updated_on"
|
||||||
|
Data = if includeData then row.bytea "data" else [||]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a theme template from the current row
|
||||||
|
let toThemeTemplate includeText (row : RowReader) : ThemeTemplate =
|
||||||
|
{ Name = row.string "name"
|
||||||
|
Text = if includeText then row.string "template" else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an uploaded file from the current row
|
||||||
|
let toUpload includeData (row : RowReader) : Upload =
|
||||||
|
{ Id = row.string "id" |> UploadId
|
||||||
|
WebLogId = row.string "web_log_id" |> WebLogId
|
||||||
|
Path = row.string "path" |> Permalink
|
||||||
|
UpdatedOn = row.fieldValue<Instant> "updated_on"
|
||||||
|
Data = if includeData then row.bytea "data" else [||]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a web log from the current row
|
||||||
|
let toWebLog (row : RowReader) : WebLog =
|
||||||
|
{ Id = row.string "id" |> WebLogId
|
||||||
|
Name = row.string "name"
|
||||||
|
Slug = row.string "slug"
|
||||||
|
Subtitle = row.stringOrNone "subtitle"
|
||||||
|
DefaultPage = row.string "default_page"
|
||||||
|
PostsPerPage = row.int "posts_per_page"
|
||||||
|
ThemeId = row.string "theme_id" |> ThemeId
|
||||||
|
UrlBase = row.string "url_base"
|
||||||
|
TimeZone = row.string "time_zone"
|
||||||
|
AutoHtmx = row.bool "auto_htmx"
|
||||||
|
Uploads = row.string "uploads" |> UploadDestination.parse
|
||||||
|
Rss = {
|
||||||
|
IsFeedEnabled = row.bool "is_feed_enabled"
|
||||||
|
FeedName = row.string "feed_name"
|
||||||
|
ItemsInFeed = row.intOrNone "items_in_feed"
|
||||||
|
IsCategoryEnabled = row.bool "is_category_enabled"
|
||||||
|
IsTagEnabled = row.bool "is_tag_enabled"
|
||||||
|
Copyright = row.stringOrNone "copyright"
|
||||||
|
CustomFeeds = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a web log user from the current row
|
||||||
|
let toWebLogUser (row : RowReader) : WebLogUser =
|
||||||
|
{ Id = row.string "id" |> WebLogUserId
|
||||||
|
WebLogId = row.string "web_log_id" |> WebLogId
|
||||||
|
Email = row.string "email"
|
||||||
|
FirstName = row.string "first_name"
|
||||||
|
LastName = row.string "last_name"
|
||||||
|
PreferredName = row.string "preferred_name"
|
||||||
|
PasswordHash = row.string "password_hash"
|
||||||
|
Url = row.stringOrNone "url"
|
||||||
|
AccessLevel = row.string "access_level" |> AccessLevel.parse
|
||||||
|
CreatedOn = row.fieldValue<Instant> "created_on"
|
||||||
|
LastSeenOn = row.fieldValueOrNone<Instant> "last_seen_on"
|
||||||
|
}
|
||||||
281
src/MyWebLog.Data/Postgres/PostgresPageData.fs
Normal file
281
src/MyWebLog.Data/Postgres/PostgresPageData.fs
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
namespace MyWebLog.Data.Postgres
|
||||||
|
|
||||||
|
open MyWebLog
|
||||||
|
open MyWebLog.Data
|
||||||
|
open Newtonsoft.Json
|
||||||
|
open Npgsql
|
||||||
|
open Npgsql.FSharp
|
||||||
|
|
||||||
|
/// PostgreSQL myWebLog page data implementation
|
||||||
|
type PostgresPageData (conn : NpgsqlConnection, ser : JsonSerializer) =
|
||||||
|
|
||||||
|
// SUPPORT FUNCTIONS
|
||||||
|
|
||||||
|
/// Append revisions and permalinks to a page
|
||||||
|
let appendPageRevisions (page : Page) = backgroundTask {
|
||||||
|
let! revisions =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT as_of, revision_text FROM page_revision WHERE page_id = @pageId ORDER BY as_of DESC"
|
||||||
|
|> Sql.parameters [ "@pageId", Sql.string (PageId.toString page.Id) ]
|
||||||
|
|> Sql.executeAsync Map.toRevision
|
||||||
|
return { page with Revisions = revisions }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shorthand to map to a page
|
||||||
|
let toPage = Map.toPage ser
|
||||||
|
|
||||||
|
/// Return a page with no text or revisions
|
||||||
|
let pageWithoutText row =
|
||||||
|
{ toPage row with Text = "" }
|
||||||
|
|
||||||
|
/// The INSERT statement for a page revision
|
||||||
|
let revInsert = "INSERT INTO page_revision VALUES (@pageId, @asOf, @text)"
|
||||||
|
|
||||||
|
/// Parameters for a revision INSERT statement
|
||||||
|
let revParams pageId rev = [
|
||||||
|
typedParam "asOf" rev.AsOf
|
||||||
|
"@pageId", Sql.string (PageId.toString pageId)
|
||||||
|
"@text", Sql.string (MarkupText.toString rev.Text)
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Update a page's revisions
|
||||||
|
let updatePageRevisions pageId oldRevs newRevs = backgroundTask {
|
||||||
|
let toDelete, toAdd = Utils.diffRevisions oldRevs newRevs
|
||||||
|
if not (List.isEmpty toDelete) || not (List.isEmpty toAdd) then
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.executeTransactionAsync [
|
||||||
|
if not (List.isEmpty toDelete) then
|
||||||
|
"DELETE FROM page_revision WHERE page_id = @pageId AND as_of = @asOf",
|
||||||
|
toDelete
|
||||||
|
|> List.map (fun it -> [
|
||||||
|
"@pageId", Sql.string (PageId.toString pageId)
|
||||||
|
typedParam "asOf" it.AsOf
|
||||||
|
])
|
||||||
|
if not (List.isEmpty toAdd) then
|
||||||
|
revInsert, toAdd |> List.map (revParams pageId)
|
||||||
|
]
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does the given page exist?
|
||||||
|
let pageExists pageId webLogId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"SELECT EXISTS (SELECT 1 FROM page WHERE id = @id AND web_log_id = @webLogId) AS {existsName}"
|
||||||
|
|> Sql.parameters [ "@id", Sql.string (PageId.toString pageId); webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeRowAsync Map.toExists
|
||||||
|
|
||||||
|
// IMPLEMENTATION FUNCTIONS
|
||||||
|
|
||||||
|
/// Get all pages for a web log (without text, revisions, prior permalinks, or metadata)
|
||||||
|
let all webLogId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM page WHERE web_log_id = @webLogId ORDER BY LOWER(title)"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeAsync pageWithoutText
|
||||||
|
|
||||||
|
/// Count all pages for the given web log
|
||||||
|
let countAll webLogId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"SELECT COUNT(id) AS {countName} FROM page WHERE web_log_id = @webLogId"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeRowAsync Map.toCount
|
||||||
|
|
||||||
|
/// Count all pages shown in the page list for the given web log
|
||||||
|
let countListed webLogId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"
|
||||||
|
SELECT COUNT(id) AS {countName}
|
||||||
|
FROM page
|
||||||
|
WHERE web_log_id = @webLogId
|
||||||
|
AND is_in_page_list = TRUE"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeRowAsync Map.toCount
|
||||||
|
|
||||||
|
/// Find a page by its ID (without revisions)
|
||||||
|
let findById pageId webLogId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM page WHERE id = @id AND web_log_id = @webLogId"
|
||||||
|
|> Sql.parameters [ "@id", Sql.string (PageId.toString pageId); webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeAsync toPage
|
||||||
|
|> tryHead
|
||||||
|
|
||||||
|
/// Find a complete page by its ID
|
||||||
|
let findFullById pageId webLogId = backgroundTask {
|
||||||
|
match! findById pageId webLogId with
|
||||||
|
| Some page ->
|
||||||
|
let! withMore = appendPageRevisions page
|
||||||
|
return Some withMore
|
||||||
|
| None -> return None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a page by its ID
|
||||||
|
let delete pageId webLogId = backgroundTask {
|
||||||
|
match! pageExists pageId webLogId with
|
||||||
|
| true ->
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query
|
||||||
|
"DELETE FROM page_revision WHERE page_id = @id;
|
||||||
|
DELETE FROM page WHERE id = @id"
|
||||||
|
|> Sql.parameters [ "@id", Sql.string (PageId.toString pageId) ]
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
return true
|
||||||
|
| false -> return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a page by its permalink for the given web log
|
||||||
|
let findByPermalink permalink webLogId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM page WHERE web_log_id = @webLogId AND permalink = @link"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId; "@link", Sql.string (Permalink.toString permalink) ]
|
||||||
|
|> Sql.executeAsync toPage
|
||||||
|
|> tryHead
|
||||||
|
|
||||||
|
/// Find the current permalink within a set of potential prior permalinks for the given web log
|
||||||
|
let findCurrentPermalink permalinks webLogId = backgroundTask {
|
||||||
|
if List.isEmpty permalinks then return None
|
||||||
|
else
|
||||||
|
let linkSql, linkParams = arrayInClause "prior_permalinks" Permalink.toString permalinks
|
||||||
|
return!
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"SELECT permalink FROM page WHERE web_log_id = @webLogId AND ({linkSql})"
|
||||||
|
|> Sql.parameters (webLogIdParam webLogId :: linkParams)
|
||||||
|
|> Sql.executeAsync Map.toPermalink
|
||||||
|
|> tryHead
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all complete pages for the given web log
|
||||||
|
let findFullByWebLog webLogId = backgroundTask {
|
||||||
|
let! pages =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM page WHERE web_log_id = @webLogId"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeAsync toPage
|
||||||
|
let! revisions =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query
|
||||||
|
"SELECT *
|
||||||
|
FROM page_revision pr
|
||||||
|
INNER JOIN page p ON p.id = pr.page_id
|
||||||
|
WHERE p.web_log_id = @webLogId
|
||||||
|
ORDER BY pr.as_of DESC"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeAsync (fun row -> PageId (row.string "page_id"), Map.toRevision row)
|
||||||
|
return
|
||||||
|
pages
|
||||||
|
|> List.map (fun it ->
|
||||||
|
{ it with Revisions = revisions |> List.filter (fun r -> fst r = it.Id) |> List.map snd })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all listed pages for the given web log (without revisions or text)
|
||||||
|
let findListed webLogId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM page WHERE web_log_id = @webLogId AND is_in_page_list = TRUE ORDER BY LOWER(title)"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeAsync pageWithoutText
|
||||||
|
|
||||||
|
/// Get a page of pages for the given web log (without revisions)
|
||||||
|
let findPageOfPages webLogId pageNbr =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query
|
||||||
|
"SELECT *
|
||||||
|
FROM page
|
||||||
|
WHERE web_log_id = @webLogId
|
||||||
|
ORDER BY LOWER(title)
|
||||||
|
LIMIT @pageSize OFFSET @toSkip"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId; "@pageSize", Sql.int 26; "@toSkip", Sql.int ((pageNbr - 1) * 25) ]
|
||||||
|
|> Sql.executeAsync toPage
|
||||||
|
|
||||||
|
/// The INSERT statement for a page
|
||||||
|
let pageInsert =
|
||||||
|
"INSERT INTO page (
|
||||||
|
id, web_log_id, author_id, title, permalink, prior_permalinks, published_on, updated_on, is_in_page_list,
|
||||||
|
template, page_text, meta_items
|
||||||
|
) VALUES (
|
||||||
|
@id, @webLogId, @authorId, @title, @permalink, @priorPermalinks, @publishedOn, @updatedOn, @isInPageList,
|
||||||
|
@template, @text, @metaItems
|
||||||
|
)"
|
||||||
|
|
||||||
|
/// The parameters for saving a page
|
||||||
|
let pageParams (page : Page) = [
|
||||||
|
webLogIdParam page.WebLogId
|
||||||
|
"@id", Sql.string (PageId.toString page.Id)
|
||||||
|
"@authorId", Sql.string (WebLogUserId.toString page.AuthorId)
|
||||||
|
"@title", Sql.string page.Title
|
||||||
|
"@permalink", Sql.string (Permalink.toString page.Permalink)
|
||||||
|
"@isInPageList", Sql.bool page.IsInPageList
|
||||||
|
"@template", Sql.stringOrNone page.Template
|
||||||
|
"@text", Sql.string page.Text
|
||||||
|
"@metaItems", Sql.jsonb (Utils.serialize ser page.Metadata)
|
||||||
|
"@priorPermalinks", Sql.stringArray (page.PriorPermalinks |> List.map Permalink.toString |> Array.ofList)
|
||||||
|
typedParam "publishedOn" page.PublishedOn
|
||||||
|
typedParam "updatedOn" page.UpdatedOn
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Restore pages from a backup
|
||||||
|
let restore (pages : Page list) = backgroundTask {
|
||||||
|
let revisions = pages |> List.collect (fun p -> p.Revisions |> List.map (fun r -> p.Id, r))
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.executeTransactionAsync [
|
||||||
|
pageInsert, pages |> List.map pageParams
|
||||||
|
revInsert, revisions |> List.map (fun (pageId, rev) -> revParams pageId rev)
|
||||||
|
]
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save a page
|
||||||
|
let save (page : Page) = backgroundTask {
|
||||||
|
let! oldPage = findFullById page.Id page.WebLogId
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"
|
||||||
|
{pageInsert} ON CONFLICT (id) DO UPDATE
|
||||||
|
SET author_id = EXCLUDED.author_id,
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
permalink = EXCLUDED.permalink,
|
||||||
|
prior_permalinks = EXCLUDED.prior_permalinks,
|
||||||
|
published_on = EXCLUDED.published_on,
|
||||||
|
updated_on = EXCLUDED.updated_on,
|
||||||
|
is_in_page_list = EXCLUDED.is_in_page_list,
|
||||||
|
template = EXCLUDED.template,
|
||||||
|
page_text = EXCLUDED.page_text,
|
||||||
|
meta_items = EXCLUDED.meta_items"
|
||||||
|
|> Sql.parameters (pageParams page)
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
do! updatePageRevisions page.Id (match oldPage with Some p -> p.Revisions | None -> []) page.Revisions
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a page's prior permalinks
|
||||||
|
let updatePriorPermalinks pageId webLogId permalinks = backgroundTask {
|
||||||
|
match! pageExists pageId webLogId with
|
||||||
|
| true ->
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "UPDATE page SET prior_permalinks = @prior WHERE id = @id"
|
||||||
|
|> Sql.parameters
|
||||||
|
[ "@id", Sql.string (PageId.toString pageId)
|
||||||
|
"@prior", Sql.stringArray (permalinks |> List.map Permalink.toString |> Array.ofList) ]
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
return true
|
||||||
|
| false -> return false
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPageData with
|
||||||
|
member _.Add page = save page
|
||||||
|
member _.All webLogId = all webLogId
|
||||||
|
member _.CountAll webLogId = countAll webLogId
|
||||||
|
member _.CountListed webLogId = countListed webLogId
|
||||||
|
member _.Delete pageId webLogId = delete pageId webLogId
|
||||||
|
member _.FindById pageId webLogId = findById pageId webLogId
|
||||||
|
member _.FindByPermalink permalink webLogId = findByPermalink permalink webLogId
|
||||||
|
member _.FindCurrentPermalink permalinks webLogId = findCurrentPermalink permalinks webLogId
|
||||||
|
member _.FindFullById pageId webLogId = findFullById pageId webLogId
|
||||||
|
member _.FindFullByWebLog webLogId = findFullByWebLog webLogId
|
||||||
|
member _.FindListed webLogId = findListed webLogId
|
||||||
|
member _.FindPageOfPages webLogId pageNbr = findPageOfPages webLogId pageNbr
|
||||||
|
member _.Restore pages = restore pages
|
||||||
|
member _.Update page = save page
|
||||||
|
member _.UpdatePriorPermalinks pageId webLogId permalinks = updatePriorPermalinks pageId webLogId permalinks
|
||||||
378
src/MyWebLog.Data/Postgres/PostgresPostData.fs
Normal file
378
src/MyWebLog.Data/Postgres/PostgresPostData.fs
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
namespace MyWebLog.Data.Postgres
|
||||||
|
|
||||||
|
open MyWebLog
|
||||||
|
open MyWebLog.Data
|
||||||
|
open Newtonsoft.Json
|
||||||
|
open NodaTime
|
||||||
|
open Npgsql
|
||||||
|
open Npgsql.FSharp
|
||||||
|
|
||||||
|
/// PostgreSQL myWebLog post data implementation
|
||||||
|
type PostgresPostData (conn : NpgsqlConnection, ser : JsonSerializer) =
|
||||||
|
|
||||||
|
// SUPPORT FUNCTIONS
|
||||||
|
|
||||||
|
/// Append revisions to a post
|
||||||
|
let appendPostRevisions (post : Post) = backgroundTask {
|
||||||
|
let! revisions =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT as_of, revision_text FROM post_revision WHERE post_id = @id ORDER BY as_of DESC"
|
||||||
|
|> Sql.parameters [ "@id", Sql.string (PostId.toString post.Id) ]
|
||||||
|
|> Sql.executeAsync Map.toRevision
|
||||||
|
return { post with Revisions = revisions }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The SELECT statement for a post that will include category IDs
|
||||||
|
let selectPost =
|
||||||
|
"SELECT *, ARRAY(SELECT cat.category_id FROM post_category cat WHERE cat.post_id = p.id) AS category_ids
|
||||||
|
FROM post p"
|
||||||
|
|
||||||
|
/// Shorthand for mapping to a post
|
||||||
|
let toPost = Map.toPost ser
|
||||||
|
|
||||||
|
/// Return a post with no revisions, prior permalinks, or text
|
||||||
|
let postWithoutText row =
|
||||||
|
{ toPost row with Text = "" }
|
||||||
|
|
||||||
|
/// The INSERT statement for a post/category cross-reference
|
||||||
|
let catInsert = "INSERT INTO post_category VALUES (@postId, @categoryId)"
|
||||||
|
|
||||||
|
/// Parameters for adding or updating a post/category cross-reference
|
||||||
|
let catParams postId cat = [
|
||||||
|
"@postId", Sql.string (PostId.toString postId)
|
||||||
|
"categoryId", Sql.string (CategoryId.toString cat)
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Update a post's assigned categories
|
||||||
|
let updatePostCategories postId oldCats newCats = backgroundTask {
|
||||||
|
let toDelete, toAdd = Utils.diffLists oldCats newCats CategoryId.toString
|
||||||
|
if not (List.isEmpty toDelete) || not (List.isEmpty toAdd) then
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.executeTransactionAsync [
|
||||||
|
if not (List.isEmpty toDelete) then
|
||||||
|
"DELETE FROM post_category WHERE post_id = @postId AND category_id = @categoryId",
|
||||||
|
toDelete |> List.map (catParams postId)
|
||||||
|
if not (List.isEmpty toAdd) then
|
||||||
|
catInsert, toAdd |> List.map (catParams postId)
|
||||||
|
]
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The INSERT statement for a post revision
|
||||||
|
let revInsert = "INSERT INTO post_revision VALUES (@postId, @asOf, @text)"
|
||||||
|
|
||||||
|
/// The parameters for adding a post revision
|
||||||
|
let revParams postId rev = [
|
||||||
|
typedParam "asOf" rev.AsOf
|
||||||
|
"@postId", Sql.string (PostId.toString postId)
|
||||||
|
"@text", Sql.string (MarkupText.toString rev.Text)
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Update a post's revisions
|
||||||
|
let updatePostRevisions postId oldRevs newRevs = backgroundTask {
|
||||||
|
let toDelete, toAdd = Utils.diffRevisions oldRevs newRevs
|
||||||
|
if not (List.isEmpty toDelete) || not (List.isEmpty toAdd) then
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.executeTransactionAsync [
|
||||||
|
if not (List.isEmpty toDelete) then
|
||||||
|
"DELETE FROM post_revision WHERE post_id = @postId AND as_of = @asOf",
|
||||||
|
toDelete
|
||||||
|
|> List.map (fun it -> [
|
||||||
|
"@postId", Sql.string (PostId.toString postId)
|
||||||
|
typedParam "asOf" it.AsOf
|
||||||
|
])
|
||||||
|
if not (List.isEmpty toAdd) then
|
||||||
|
revInsert, toAdd |> List.map (revParams postId)
|
||||||
|
]
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does the given post exist?
|
||||||
|
let postExists postId webLogId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"SELECT EXISTS (SELECT 1 FROM post WHERE id = @id AND web_log_id = @webLogId) AS {existsName}"
|
||||||
|
|> Sql.parameters [ "@id", Sql.string (PostId.toString postId); webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeRowAsync Map.toExists
|
||||||
|
|
||||||
|
// IMPLEMENTATION FUNCTIONS
|
||||||
|
|
||||||
|
/// Count posts in a status for the given web log
|
||||||
|
let countByStatus status webLogId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"SELECT COUNT(id) AS {countName} FROM post WHERE web_log_id = @webLogId AND status = @status"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId; "@status", Sql.string (PostStatus.toString status) ]
|
||||||
|
|> Sql.executeRowAsync Map.toCount
|
||||||
|
|
||||||
|
/// Find a post by its ID for the given web log (excluding revisions)
|
||||||
|
let findById postId webLogId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"{selectPost} WHERE id = @id AND web_log_id = @webLogId"
|
||||||
|
|> Sql.parameters [ "@id", Sql.string (PostId.toString postId); webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeAsync toPost
|
||||||
|
|> tryHead
|
||||||
|
|
||||||
|
/// Find a post by its permalink for the given web log (excluding revisions and prior permalinks)
|
||||||
|
let findByPermalink permalink webLogId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"{selectPost} WHERE web_log_id = @webLogId AND permalink = @link"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId; "@link", Sql.string (Permalink.toString permalink) ]
|
||||||
|
|> Sql.executeAsync toPost
|
||||||
|
|> tryHead
|
||||||
|
|
||||||
|
/// Find a complete post by its ID for the given web log
|
||||||
|
let findFullById postId webLogId = backgroundTask {
|
||||||
|
match! findById postId webLogId with
|
||||||
|
| Some post ->
|
||||||
|
let! withRevisions = appendPostRevisions post
|
||||||
|
return Some withRevisions
|
||||||
|
| None -> return None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a post by its ID for the given web log
|
||||||
|
let delete postId webLogId = backgroundTask {
|
||||||
|
match! postExists postId webLogId with
|
||||||
|
| true ->
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query
|
||||||
|
"DELETE FROM post_revision WHERE post_id = @id;
|
||||||
|
DELETE FROM post_category WHERE post_id = @id;
|
||||||
|
DELETE FROM post WHERE id = @id"
|
||||||
|
|> Sql.parameters [ "@id", Sql.string (PostId.toString postId) ]
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
return true
|
||||||
|
| false -> return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the current permalink from a list of potential prior permalinks for the given web log
|
||||||
|
let findCurrentPermalink permalinks webLogId = backgroundTask {
|
||||||
|
if List.isEmpty permalinks then return None
|
||||||
|
else
|
||||||
|
let linkSql, linkParams = arrayInClause "prior_permalinks" Permalink.toString permalinks
|
||||||
|
return!
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"SELECT permalink FROM post WHERE web_log_id = @webLogId AND ({linkSql})"
|
||||||
|
|> Sql.parameters (webLogIdParam webLogId :: linkParams)
|
||||||
|
|> Sql.executeAsync Map.toPermalink
|
||||||
|
|> tryHead
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all complete posts for the given web log
|
||||||
|
let findFullByWebLog webLogId = backgroundTask {
|
||||||
|
let! posts =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"{selectPost} WHERE web_log_id = @webLogId"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeAsync toPost
|
||||||
|
let! revisions =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query
|
||||||
|
"SELECT *
|
||||||
|
FROM post_revision pr
|
||||||
|
INNER JOIN post p ON p.id = pr.post_id
|
||||||
|
WHERE p.web_log_id = @webLogId
|
||||||
|
ORDER BY as_of DESC"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeAsync (fun row -> PostId (row.string "post_id"), Map.toRevision row)
|
||||||
|
return
|
||||||
|
posts
|
||||||
|
|> List.map (fun it ->
|
||||||
|
{ it with Revisions = revisions |> List.filter (fun r -> fst r = it.Id) |> List.map snd })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a page of categorized posts for the given web log (excludes revisions)
|
||||||
|
let findPageOfCategorizedPosts webLogId categoryIds pageNbr postsPerPage =
|
||||||
|
let catSql, catParams = inClause "AND pc.category_id" "catId" CategoryId.toString categoryIds
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"
|
||||||
|
{selectPost}
|
||||||
|
INNER JOIN post_category pc ON pc.post_id = p.id
|
||||||
|
WHERE p.web_log_id = @webLogId
|
||||||
|
AND p.status = @status
|
||||||
|
{catSql}
|
||||||
|
ORDER BY published_on DESC
|
||||||
|
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
|
||||||
|
|> Sql.parameters
|
||||||
|
[ webLogIdParam webLogId
|
||||||
|
"@status", Sql.string (PostStatus.toString Published)
|
||||||
|
yield! catParams ]
|
||||||
|
|> Sql.executeAsync toPost
|
||||||
|
|
||||||
|
/// Get a page of posts for the given web log (excludes text and revisions)
|
||||||
|
let findPageOfPosts webLogId pageNbr postsPerPage =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"
|
||||||
|
{selectPost}
|
||||||
|
WHERE web_log_id = @webLogId
|
||||||
|
ORDER BY published_on DESC NULLS FIRST, updated_on
|
||||||
|
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeAsync postWithoutText
|
||||||
|
|
||||||
|
/// Get a page of published posts for the given web log (excludes revisions)
|
||||||
|
let findPageOfPublishedPosts webLogId pageNbr postsPerPage =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"
|
||||||
|
{selectPost}
|
||||||
|
WHERE web_log_id = @webLogId
|
||||||
|
AND status = @status
|
||||||
|
ORDER BY published_on DESC
|
||||||
|
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId; "@status", Sql.string (PostStatus.toString Published) ]
|
||||||
|
|> Sql.executeAsync toPost
|
||||||
|
|
||||||
|
/// Get a page of tagged posts for the given web log (excludes revisions and prior permalinks)
|
||||||
|
let findPageOfTaggedPosts webLogId (tag : string) pageNbr postsPerPage =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"
|
||||||
|
{selectPost}
|
||||||
|
WHERE web_log_id = @webLogId
|
||||||
|
AND status = @status
|
||||||
|
AND tags && ARRAY[@tag]
|
||||||
|
ORDER BY published_on DESC
|
||||||
|
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
|
||||||
|
|> Sql.parameters
|
||||||
|
[ webLogIdParam webLogId
|
||||||
|
"@status", Sql.string (PostStatus.toString Published)
|
||||||
|
"@tag", Sql.string tag
|
||||||
|
]
|
||||||
|
|> Sql.executeAsync toPost
|
||||||
|
|
||||||
|
/// Find the next newest and oldest post from a publish date for the given web log
|
||||||
|
let findSurroundingPosts webLogId (publishedOn : Instant) = backgroundTask {
|
||||||
|
let queryParams () = Sql.parameters [
|
||||||
|
webLogIdParam webLogId
|
||||||
|
typedParam "publishedOn" publishedOn
|
||||||
|
"@status", Sql.string (PostStatus.toString Published)
|
||||||
|
]
|
||||||
|
let! older =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"
|
||||||
|
{selectPost}
|
||||||
|
WHERE web_log_id = @webLogId
|
||||||
|
AND status = @status
|
||||||
|
AND published_on < @publishedOn
|
||||||
|
ORDER BY published_on DESC
|
||||||
|
LIMIT 1"
|
||||||
|
|> queryParams ()
|
||||||
|
|> Sql.executeAsync toPost
|
||||||
|
let! newer =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"
|
||||||
|
{selectPost}
|
||||||
|
WHERE web_log_id = @webLogId
|
||||||
|
AND status = @status
|
||||||
|
AND published_on > @publishedOn
|
||||||
|
ORDER BY published_on
|
||||||
|
LIMIT 1"
|
||||||
|
|> queryParams ()
|
||||||
|
|> Sql.executeAsync toPost
|
||||||
|
return List.tryHead older, List.tryHead newer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The INSERT statement for a post
|
||||||
|
let postInsert =
|
||||||
|
"INSERT INTO post (
|
||||||
|
id, web_log_id, author_id, status, title, permalink, prior_permalinks, published_on, updated_on,
|
||||||
|
template, post_text, tags, meta_items, episode
|
||||||
|
) VALUES (
|
||||||
|
@id, @webLogId, @authorId, @status, @title, @permalink, @priorPermalinks, @publishedOn, @updatedOn,
|
||||||
|
@template, @text, @tags, @metaItems, @episode
|
||||||
|
)"
|
||||||
|
|
||||||
|
/// The parameters for saving a post
|
||||||
|
let postParams (post : Post) = [
|
||||||
|
webLogIdParam post.WebLogId
|
||||||
|
"@id", Sql.string (PostId.toString post.Id)
|
||||||
|
"@authorId", Sql.string (WebLogUserId.toString post.AuthorId)
|
||||||
|
"@status", Sql.string (PostStatus.toString post.Status)
|
||||||
|
"@title", Sql.string post.Title
|
||||||
|
"@permalink", Sql.string (Permalink.toString post.Permalink)
|
||||||
|
"@template", Sql.stringOrNone post.Template
|
||||||
|
"@text", Sql.string post.Text
|
||||||
|
"@priorPermalinks", Sql.stringArray (post.PriorPermalinks |> List.map Permalink.toString |> Array.ofList)
|
||||||
|
"@episode", Sql.jsonbOrNone (post.Episode |> Option.map (Utils.serialize ser))
|
||||||
|
"@tags", Sql.stringArrayOrNone (if List.isEmpty post.Tags then None else Some (Array.ofList post.Tags))
|
||||||
|
"@metaItems",
|
||||||
|
if List.isEmpty post.Metadata then None else Some (Utils.serialize ser post.Metadata)
|
||||||
|
|> Sql.jsonbOrNone
|
||||||
|
optParam "publishedOn" post.PublishedOn
|
||||||
|
typedParam "updatedOn" post.UpdatedOn
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Save a post
|
||||||
|
let save (post : Post) = backgroundTask {
|
||||||
|
let! oldPost = findFullById post.Id post.WebLogId
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"
|
||||||
|
{postInsert} ON CONFLICT (id) DO UPDATE
|
||||||
|
SET author_id = EXCLUDED.author_id,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
permalink = EXCLUDED.permalink,
|
||||||
|
prior_permalinks = EXCLUDED.prior_permalinks,
|
||||||
|
published_on = EXCLUDED.published_on,
|
||||||
|
updated_on = EXCLUDED.updated_on,
|
||||||
|
template = EXCLUDED.template,
|
||||||
|
post_text = EXCLUDED.post_text,
|
||||||
|
tags = EXCLUDED.tags,
|
||||||
|
meta_items = EXCLUDED.meta_items,
|
||||||
|
episode = EXCLUDED.episode"
|
||||||
|
|> Sql.parameters (postParams post)
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
do! updatePostCategories post.Id (match oldPost with Some p -> p.CategoryIds | None -> []) post.CategoryIds
|
||||||
|
do! updatePostRevisions post.Id (match oldPost with Some p -> p.Revisions | None -> []) post.Revisions
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore posts from a backup
|
||||||
|
let restore posts = backgroundTask {
|
||||||
|
let cats = posts |> List.collect (fun p -> p.CategoryIds |> List.map (fun c -> p.Id, c))
|
||||||
|
let revisions = posts |> List.collect (fun p -> p.Revisions |> List.map (fun r -> p.Id, r))
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.executeTransactionAsync [
|
||||||
|
postInsert, posts |> List.map postParams
|
||||||
|
catInsert, cats |> List.map (fun (postId, catId) -> catParams postId catId)
|
||||||
|
revInsert, revisions |> List.map (fun (postId, rev) -> revParams postId rev)
|
||||||
|
]
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update prior permalinks for a post
|
||||||
|
let updatePriorPermalinks postId webLogId permalinks = backgroundTask {
|
||||||
|
match! postExists postId webLogId with
|
||||||
|
| true ->
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "UPDATE post SET prior_permalinks = @prior WHERE id = @id"
|
||||||
|
|> Sql.parameters
|
||||||
|
[ "@id", Sql.string (PostId.toString postId)
|
||||||
|
"@prior", Sql.stringArray (permalinks |> List.map Permalink.toString |> Array.ofList) ]
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
return true
|
||||||
|
| false -> return false
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPostData with
|
||||||
|
member _.Add post = save post
|
||||||
|
member _.CountByStatus status webLogId = countByStatus status webLogId
|
||||||
|
member _.Delete postId webLogId = delete postId webLogId
|
||||||
|
member _.FindById postId webLogId = findById postId webLogId
|
||||||
|
member _.FindByPermalink permalink webLogId = findByPermalink permalink webLogId
|
||||||
|
member _.FindCurrentPermalink permalinks webLogId = findCurrentPermalink permalinks webLogId
|
||||||
|
member _.FindFullById postId webLogId = findFullById postId webLogId
|
||||||
|
member _.FindFullByWebLog webLogId = findFullByWebLog webLogId
|
||||||
|
member _.FindPageOfCategorizedPosts webLogId categoryIds pageNbr postsPerPage =
|
||||||
|
findPageOfCategorizedPosts webLogId categoryIds pageNbr postsPerPage
|
||||||
|
member _.FindPageOfPosts webLogId pageNbr postsPerPage = findPageOfPosts webLogId pageNbr postsPerPage
|
||||||
|
member _.FindPageOfPublishedPosts webLogId pageNbr postsPerPage =
|
||||||
|
findPageOfPublishedPosts webLogId pageNbr postsPerPage
|
||||||
|
member _.FindPageOfTaggedPosts webLogId tag pageNbr postsPerPage =
|
||||||
|
findPageOfTaggedPosts webLogId tag pageNbr postsPerPage
|
||||||
|
member _.FindSurroundingPosts webLogId publishedOn = findSurroundingPosts webLogId publishedOn
|
||||||
|
member _.Restore posts = restore posts
|
||||||
|
member _.Update post = save post
|
||||||
|
member _.UpdatePriorPermalinks postId webLogId permalinks = updatePriorPermalinks postId webLogId permalinks
|
||||||
109
src/MyWebLog.Data/Postgres/PostgresTagMapData.fs
Normal file
109
src/MyWebLog.Data/Postgres/PostgresTagMapData.fs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
namespace MyWebLog.Data.Postgres
|
||||||
|
|
||||||
|
open MyWebLog
|
||||||
|
open MyWebLog.Data
|
||||||
|
open Npgsql
|
||||||
|
open Npgsql.FSharp
|
||||||
|
|
||||||
|
/// PostgreSQL myWebLog tag mapping data implementation
|
||||||
|
type PostgresTagMapData (conn : NpgsqlConnection) =
|
||||||
|
|
||||||
|
/// Find a tag mapping by its ID for the given web log
|
||||||
|
let findById tagMapId webLogId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM tag_map WHERE id = @id AND web_log_id = @webLogId"
|
||||||
|
|> Sql.parameters [ "@id", Sql.string (TagMapId.toString tagMapId); webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeAsync Map.toTagMap
|
||||||
|
|> tryHead
|
||||||
|
|
||||||
|
/// Delete a tag mapping for the given web log
|
||||||
|
let delete tagMapId webLogId = backgroundTask {
|
||||||
|
let idParams = [ "@id", Sql.string (TagMapId.toString tagMapId) ]
|
||||||
|
let! exists =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"
|
||||||
|
SELECT EXISTS
|
||||||
|
(SELECT 1 FROM tag_map WHERE id = @id AND web_log_id = @webLogId)
|
||||||
|
AS {existsName}"
|
||||||
|
|> Sql.parameters (webLogIdParam webLogId :: idParams)
|
||||||
|
|> Sql.executeRowAsync Map.toExists
|
||||||
|
if exists then
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "DELETE FROM tag_map WHERE id = @id"
|
||||||
|
|> Sql.parameters idParams
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
return true
|
||||||
|
else return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a tag mapping by its URL value for the given web log
|
||||||
|
let findByUrlValue urlValue webLogId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM tag_map WHERE web_log_id = @webLogId AND url_value = @urlValue"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId; "@urlValue", Sql.string urlValue ]
|
||||||
|
|> Sql.executeAsync Map.toTagMap
|
||||||
|
|> tryHead
|
||||||
|
|
||||||
|
/// Get all tag mappings for the given web log
|
||||||
|
let findByWebLog webLogId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM tag_map WHERE web_log_id = @webLogId ORDER BY tag"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeAsync Map.toTagMap
|
||||||
|
|
||||||
|
/// Find any tag mappings in a list of tags for the given web log
|
||||||
|
let findMappingForTags tags webLogId =
|
||||||
|
let tagSql, tagParams = inClause "AND tag" "tag" id tags
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"SELECT * FROM tag_map WHERE web_log_id = @webLogId {tagSql}"
|
||||||
|
|> Sql.parameters (webLogIdParam webLogId :: tagParams)
|
||||||
|
|> Sql.executeAsync Map.toTagMap
|
||||||
|
|
||||||
|
/// The INSERT statement for a tag mapping
|
||||||
|
let tagMapInsert =
|
||||||
|
"INSERT INTO tag_map (
|
||||||
|
id, web_log_id, tag, url_value
|
||||||
|
) VALUES (
|
||||||
|
@id, @webLogId, @tag, @urlValue
|
||||||
|
)"
|
||||||
|
|
||||||
|
/// The parameters for saving a tag mapping
|
||||||
|
let tagMapParams (tagMap : TagMap) = [
|
||||||
|
webLogIdParam tagMap.WebLogId
|
||||||
|
"@id", Sql.string (TagMapId.toString tagMap.Id)
|
||||||
|
"@tag", Sql.string tagMap.Tag
|
||||||
|
"@urlValue", Sql.string tagMap.UrlValue
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Save a tag mapping
|
||||||
|
let save tagMap = backgroundTask {
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"
|
||||||
|
{tagMapInsert} ON CONFLICT (id) DO UPDATE
|
||||||
|
SET tag = EXCLUDED.tag,
|
||||||
|
url_value = EXCLUDED.url_value"
|
||||||
|
|> Sql.parameters (tagMapParams tagMap)
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore tag mappings from a backup
|
||||||
|
let restore tagMaps = backgroundTask {
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.executeTransactionAsync [
|
||||||
|
tagMapInsert, tagMaps |> List.map tagMapParams
|
||||||
|
]
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ITagMapData with
|
||||||
|
member _.Delete tagMapId webLogId = delete tagMapId webLogId
|
||||||
|
member _.FindById tagMapId webLogId = findById tagMapId webLogId
|
||||||
|
member _.FindByUrlValue urlValue webLogId = findByUrlValue urlValue webLogId
|
||||||
|
member _.FindByWebLog webLogId = findByWebLog webLogId
|
||||||
|
member _.FindMappingForTags tags webLogId = findMappingForTags tags webLogId
|
||||||
|
member _.Save tagMap = save tagMap
|
||||||
|
member _.Restore tagMaps = restore tagMaps
|
||||||
207
src/MyWebLog.Data/Postgres/PostgresThemeData.fs
Normal file
207
src/MyWebLog.Data/Postgres/PostgresThemeData.fs
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
namespace MyWebLog.Data.Postgres
|
||||||
|
|
||||||
|
open MyWebLog
|
||||||
|
open MyWebLog.Data
|
||||||
|
open Npgsql
|
||||||
|
open Npgsql.FSharp
|
||||||
|
|
||||||
|
/// PostreSQL myWebLog theme data implementation
|
||||||
|
type PostgresThemeData (conn : NpgsqlConnection) =
|
||||||
|
|
||||||
|
/// Retrieve all themes (except 'admin'; excludes template text)
|
||||||
|
let all () = backgroundTask {
|
||||||
|
let! themes =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM theme WHERE id <> 'admin' ORDER BY id"
|
||||||
|
|> Sql.executeAsync Map.toTheme
|
||||||
|
let! templates =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT name, theme_id FROM theme_template WHERE theme_id <> 'admin' ORDER BY name"
|
||||||
|
|> Sql.executeAsync (fun row -> ThemeId (row.string "theme_id"), Map.toThemeTemplate false row)
|
||||||
|
return
|
||||||
|
themes
|
||||||
|
|> List.map (fun t ->
|
||||||
|
{ t with Templates = templates |> List.filter (fun tt -> fst tt = t.Id) |> List.map snd })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does a given theme exist?
|
||||||
|
let exists themeId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT EXISTS (SELECT 1 FROM theme WHERE id = @id) AS does_exist"
|
||||||
|
|> Sql.parameters [ "@id", Sql.string (ThemeId.toString themeId) ]
|
||||||
|
|> Sql.executeRowAsync Map.toExists
|
||||||
|
|
||||||
|
/// Find a theme by its ID
|
||||||
|
let findById themeId = backgroundTask {
|
||||||
|
let themeIdParam = [ "@id", Sql.string (ThemeId.toString themeId) ]
|
||||||
|
let! theme =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM theme WHERE id = @id"
|
||||||
|
|> Sql.parameters themeIdParam
|
||||||
|
|> Sql.executeAsync Map.toTheme
|
||||||
|
|> tryHead
|
||||||
|
if Option.isSome theme then
|
||||||
|
let! templates =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM theme_template WHERE theme_id = @id"
|
||||||
|
|> Sql.parameters themeIdParam
|
||||||
|
|> Sql.executeAsync (Map.toThemeTemplate true)
|
||||||
|
return Some { theme.Value with Templates = templates }
|
||||||
|
else return None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a theme by its ID (excludes the text of templates)
|
||||||
|
let findByIdWithoutText themeId = backgroundTask {
|
||||||
|
match! findById themeId with
|
||||||
|
| Some theme ->
|
||||||
|
return Some {
|
||||||
|
theme with Templates = theme.Templates |> List.map (fun t -> { t with Text = "" })
|
||||||
|
}
|
||||||
|
| None -> return None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a theme by its ID
|
||||||
|
let delete themeId = backgroundTask {
|
||||||
|
let idParams = [ "@id", Sql.string (ThemeId.toString themeId) ]
|
||||||
|
let! exists =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"SELECT EXISTS (SELECT 1 FROM theme WHERE id = @id) AS {existsName}"
|
||||||
|
|> Sql.parameters idParams
|
||||||
|
|> Sql.executeRowAsync Map.toExists
|
||||||
|
if exists then
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query
|
||||||
|
"DELETE FROM theme_asset WHERE theme_id = @id;
|
||||||
|
DELETE FROM theme_template WHERE theme_id = @id;
|
||||||
|
DELETE FROM theme WHERE id = @id"
|
||||||
|
|> Sql.parameters idParams
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
return true
|
||||||
|
else return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save a theme
|
||||||
|
let save (theme : Theme) = backgroundTask {
|
||||||
|
let! oldTheme = findById theme.Id
|
||||||
|
let themeIdParam = Sql.string (ThemeId.toString theme.Id)
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query
|
||||||
|
"INSERT INTO theme VALUES (@id, @name, @version)
|
||||||
|
ON CONFLICT (id) DO UPDATE
|
||||||
|
SET name = EXCLUDED.name,
|
||||||
|
version = EXCLUDED.version"
|
||||||
|
|> Sql.parameters
|
||||||
|
[ "@id", themeIdParam
|
||||||
|
"@name", Sql.string theme.Name
|
||||||
|
"@version", Sql.string theme.Version ]
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
|
||||||
|
let toDelete, _ =
|
||||||
|
Utils.diffLists (oldTheme |> Option.map (fun t -> t.Templates) |> Option.defaultValue [])
|
||||||
|
theme.Templates (fun t -> t.Name)
|
||||||
|
let toAddOrUpdate =
|
||||||
|
theme.Templates
|
||||||
|
|> List.filter (fun t -> not (toDelete |> List.exists (fun d -> d.Name = t.Name)))
|
||||||
|
|
||||||
|
if not (List.isEmpty toDelete) || not (List.isEmpty toAddOrUpdate) then
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.executeTransactionAsync [
|
||||||
|
if not (List.isEmpty toDelete) then
|
||||||
|
"DELETE FROM theme_template WHERE theme_id = @themeId AND name = @name",
|
||||||
|
toDelete |> List.map (fun tmpl -> [ "@themeId", themeIdParam; "@name", Sql.string tmpl.Name ])
|
||||||
|
if not (List.isEmpty toAddOrUpdate) then
|
||||||
|
"INSERT INTO theme_template VALUES (@themeId, @name, @template)
|
||||||
|
ON CONFLICT (theme_id, name) DO UPDATE
|
||||||
|
SET template = EXCLUDED.template",
|
||||||
|
toAddOrUpdate |> List.map (fun tmpl -> [
|
||||||
|
"@themeId", themeIdParam
|
||||||
|
"@name", Sql.string tmpl.Name
|
||||||
|
"@template", Sql.string tmpl.Text
|
||||||
|
])
|
||||||
|
]
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IThemeData with
|
||||||
|
member _.All () = all ()
|
||||||
|
member _.Delete themeId = delete themeId
|
||||||
|
member _.Exists themeId = exists themeId
|
||||||
|
member _.FindById themeId = findById themeId
|
||||||
|
member _.FindByIdWithoutText themeId = findByIdWithoutText themeId
|
||||||
|
member _.Save theme = save theme
|
||||||
|
|
||||||
|
|
||||||
|
/// PostreSQL myWebLog theme data implementation
|
||||||
|
type PostgresThemeAssetData (conn : NpgsqlConnection) =
|
||||||
|
|
||||||
|
/// Get all theme assets (excludes data)
|
||||||
|
let all () =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT theme_id, path, updated_on FROM theme_asset"
|
||||||
|
|> Sql.executeAsync (Map.toThemeAsset false)
|
||||||
|
|
||||||
|
/// Delete all assets for the given theme
|
||||||
|
let deleteByTheme themeId = backgroundTask {
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "DELETE FROM theme_asset WHERE theme_id = @themeId"
|
||||||
|
|> Sql.parameters [ "@themeId", Sql.string (ThemeId.toString themeId) ]
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a theme asset by its ID
|
||||||
|
let findById assetId =
|
||||||
|
let (ThemeAssetId (ThemeId themeId, path)) = assetId
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM theme_asset WHERE theme_id = @themeId AND path = @path"
|
||||||
|
|> Sql.parameters [ "@themeId", Sql.string themeId; "@path", Sql.string path ]
|
||||||
|
|> Sql.executeAsync (Map.toThemeAsset true)
|
||||||
|
|> tryHead
|
||||||
|
|
||||||
|
/// Get theme assets for the given theme (excludes data)
|
||||||
|
let findByTheme themeId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT theme_id, path, updated_on FROM theme_asset WHERE theme_id = @themeId"
|
||||||
|
|> Sql.parameters [ "@themeId", Sql.string (ThemeId.toString themeId) ]
|
||||||
|
|> Sql.executeAsync (Map.toThemeAsset false)
|
||||||
|
|
||||||
|
/// Get theme assets for the given theme
|
||||||
|
let findByThemeWithData themeId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM theme_asset WHERE theme_id = @themeId"
|
||||||
|
|> Sql.parameters [ "@themeId", Sql.string (ThemeId.toString themeId) ]
|
||||||
|
|> Sql.executeAsync (Map.toThemeAsset true)
|
||||||
|
|
||||||
|
/// Save a theme asset
|
||||||
|
let save (asset : ThemeAsset) = backgroundTask {
|
||||||
|
let (ThemeAssetId (ThemeId themeId, path)) = asset.Id
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query
|
||||||
|
"INSERT INTO theme_asset (
|
||||||
|
theme_id, path, updated_on, data
|
||||||
|
) VALUES (
|
||||||
|
@themeId, @path, @updatedOn, @data
|
||||||
|
) ON CONFLICT (theme_id, path) DO UPDATE
|
||||||
|
SET updated_on = EXCLUDED.updated_on,
|
||||||
|
data = EXCLUDED.data"
|
||||||
|
|> Sql.parameters
|
||||||
|
[ "@themeId", Sql.string themeId
|
||||||
|
"@path", Sql.string path
|
||||||
|
"@data", Sql.bytea asset.Data
|
||||||
|
typedParam "updatedOn" asset.UpdatedOn ]
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IThemeAssetData with
|
||||||
|
member _.All () = all ()
|
||||||
|
member _.DeleteByTheme themeId = deleteByTheme themeId
|
||||||
|
member _.FindById assetId = findById assetId
|
||||||
|
member _.FindByTheme themeId = findByTheme themeId
|
||||||
|
member _.FindByThemeWithData themeId = findByThemeWithData themeId
|
||||||
|
member _.Save asset = save asset
|
||||||
97
src/MyWebLog.Data/Postgres/PostgresUploadData.fs
Normal file
97
src/MyWebLog.Data/Postgres/PostgresUploadData.fs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
namespace MyWebLog.Data.Postgres
|
||||||
|
|
||||||
|
open MyWebLog
|
||||||
|
open MyWebLog.Data
|
||||||
|
open Npgsql
|
||||||
|
open Npgsql.FSharp
|
||||||
|
|
||||||
|
/// PostgreSQL myWebLog uploaded file data implementation
|
||||||
|
type PostgresUploadData (conn : NpgsqlConnection) =
|
||||||
|
|
||||||
|
/// The INSERT statement for an uploaded file
|
||||||
|
let upInsert =
|
||||||
|
"INSERT INTO upload (
|
||||||
|
id, web_log_id, path, updated_on, data
|
||||||
|
) VALUES (
|
||||||
|
@id, @webLogId, @path, @updatedOn, @data
|
||||||
|
)"
|
||||||
|
|
||||||
|
/// Parameters for adding an uploaded file
|
||||||
|
let upParams (upload : Upload) = [
|
||||||
|
webLogIdParam upload.WebLogId
|
||||||
|
typedParam "updatedOn" upload.UpdatedOn
|
||||||
|
"@id", Sql.string (UploadId.toString upload.Id)
|
||||||
|
"@path", Sql.string (Permalink.toString upload.Path)
|
||||||
|
"@data", Sql.bytea upload.Data
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Save an uploaded file
|
||||||
|
let add upload = backgroundTask {
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query upInsert
|
||||||
|
|> Sql.parameters (upParams upload)
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an uploaded file by its ID
|
||||||
|
let delete uploadId webLogId = backgroundTask {
|
||||||
|
let theParams = [ "@id", Sql.string (UploadId.toString uploadId); webLogIdParam webLogId ]
|
||||||
|
let! path =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT path FROM upload WHERE id = @id AND web_log_id = @webLogId"
|
||||||
|
|> Sql.parameters theParams
|
||||||
|
|> Sql.executeAsync (fun row -> row.string "path")
|
||||||
|
|> tryHead
|
||||||
|
if Option.isSome path then
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "DELETE FROM upload WHERE id = @id AND web_log_id = @webLogId"
|
||||||
|
|> Sql.parameters theParams
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
return Ok path.Value
|
||||||
|
else return Error $"""Upload ID {UploadId.toString uploadId} not found"""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find an uploaded file by its path for the given web log
|
||||||
|
let findByPath path webLogId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM upload WHERE web_log_id = @webLogId AND path = @path"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId; "@path", Sql.string path ]
|
||||||
|
|> Sql.executeAsync (Map.toUpload true)
|
||||||
|
|> tryHead
|
||||||
|
|
||||||
|
/// Find all uploaded files for the given web log (excludes data)
|
||||||
|
let findByWebLog webLogId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT id, web_log_id, path, updated_on FROM upload WHERE web_log_id = @webLogId"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeAsync (Map.toUpload false)
|
||||||
|
|
||||||
|
/// Find all uploaded files for the given web log
|
||||||
|
let findByWebLogWithData webLogId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM upload WHERE web_log_id = @webLogId"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeAsync (Map.toUpload true)
|
||||||
|
|
||||||
|
/// Restore uploads from a backup
|
||||||
|
let restore uploads = backgroundTask {
|
||||||
|
for batch in uploads |> List.chunkBySize 5 do
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.executeTransactionAsync [
|
||||||
|
upInsert, batch |> List.map upParams
|
||||||
|
]
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IUploadData with
|
||||||
|
member _.Add upload = add upload
|
||||||
|
member _.Delete uploadId webLogId = delete uploadId webLogId
|
||||||
|
member _.FindByPath path webLogId = findByPath path webLogId
|
||||||
|
member _.FindByWebLog webLogId = findByWebLog webLogId
|
||||||
|
member _.FindByWebLogWithData webLogId = findByWebLogWithData webLogId
|
||||||
|
member _.Restore uploads = restore uploads
|
||||||
|
|
||||||
238
src/MyWebLog.Data/Postgres/PostgresWebLogData.fs
Normal file
238
src/MyWebLog.Data/Postgres/PostgresWebLogData.fs
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
namespace MyWebLog.Data.Postgres
|
||||||
|
|
||||||
|
open MyWebLog
|
||||||
|
open MyWebLog.Data
|
||||||
|
open Newtonsoft.Json
|
||||||
|
open Npgsql
|
||||||
|
open Npgsql.FSharp
|
||||||
|
|
||||||
|
/// PostgreSQL myWebLog web log data implementation
|
||||||
|
type PostgresWebLogData (conn : NpgsqlConnection, ser : JsonSerializer) =
|
||||||
|
|
||||||
|
// SUPPORT FUNCTIONS
|
||||||
|
|
||||||
|
/// The parameters for web log INSERT or web log/RSS options UPDATE statements
|
||||||
|
let rssParams (webLog : WebLog) = [
|
||||||
|
"@isFeedEnabled", Sql.bool webLog.Rss.IsFeedEnabled
|
||||||
|
"@feedName", Sql.string webLog.Rss.FeedName
|
||||||
|
"@itemsInFeed", Sql.intOrNone webLog.Rss.ItemsInFeed
|
||||||
|
"@isCategoryEnabled", Sql.bool webLog.Rss.IsCategoryEnabled
|
||||||
|
"@isTagEnabled", Sql.bool webLog.Rss.IsTagEnabled
|
||||||
|
"@copyright", Sql.stringOrNone webLog.Rss.Copyright
|
||||||
|
]
|
||||||
|
|
||||||
|
/// The parameters for web log INSERT or UPDATE statements
|
||||||
|
let webLogParams (webLog : WebLog) = [
|
||||||
|
"@id", Sql.string (WebLogId.toString webLog.Id)
|
||||||
|
"@name", Sql.string webLog.Name
|
||||||
|
"@slug", Sql.string webLog.Slug
|
||||||
|
"@subtitle", Sql.stringOrNone webLog.Subtitle
|
||||||
|
"@defaultPage", Sql.string webLog.DefaultPage
|
||||||
|
"@postsPerPage", Sql.int webLog.PostsPerPage
|
||||||
|
"@themeId", Sql.string (ThemeId.toString webLog.ThemeId)
|
||||||
|
"@urlBase", Sql.string webLog.UrlBase
|
||||||
|
"@timeZone", Sql.string webLog.TimeZone
|
||||||
|
"@autoHtmx", Sql.bool webLog.AutoHtmx
|
||||||
|
"@uploads", Sql.string (UploadDestination.toString webLog.Uploads)
|
||||||
|
yield! rssParams webLog
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Shorthand to map a result to a custom feed
|
||||||
|
let toCustomFeed =
|
||||||
|
Map.toCustomFeed ser
|
||||||
|
|
||||||
|
/// Get the current custom feeds for a web log
|
||||||
|
let getCustomFeeds (webLog : WebLog) =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM web_log_feed WHERE web_log_id = @webLogId"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLog.Id ]
|
||||||
|
|> Sql.executeAsync toCustomFeed
|
||||||
|
|
||||||
|
/// Append custom feeds to a web log
|
||||||
|
let appendCustomFeeds (webLog : WebLog) = backgroundTask {
|
||||||
|
let! feeds = getCustomFeeds webLog
|
||||||
|
return { webLog with Rss = { webLog.Rss with CustomFeeds = feeds } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The parameters to save a custom feed
|
||||||
|
let feedParams webLogId (feed : CustomFeed) = [
|
||||||
|
webLogIdParam webLogId
|
||||||
|
"@id", Sql.string (CustomFeedId.toString feed.Id)
|
||||||
|
"@source", Sql.string (CustomFeedSource.toString feed.Source)
|
||||||
|
"@path", Sql.string (Permalink.toString feed.Path)
|
||||||
|
"@podcast", Sql.jsonbOrNone (feed.Podcast |> Option.map (Utils.serialize ser))
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Update the custom feeds for a web log
|
||||||
|
let updateCustomFeeds (webLog : WebLog) = backgroundTask {
|
||||||
|
let! feeds = getCustomFeeds webLog
|
||||||
|
let toDelete, _ = Utils.diffLists feeds webLog.Rss.CustomFeeds (fun it -> $"{CustomFeedId.toString it.Id}")
|
||||||
|
let toId (feed : CustomFeed) = feed.Id
|
||||||
|
let toAddOrUpdate =
|
||||||
|
webLog.Rss.CustomFeeds |> List.filter (fun f -> not (toDelete |> List.map toId |> List.contains f.Id))
|
||||||
|
if not (List.isEmpty toDelete) || not (List.isEmpty toAddOrUpdate) then
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.executeTransactionAsync [
|
||||||
|
if not (List.isEmpty toDelete) then
|
||||||
|
"DELETE FROM web_log_feed WHERE id = @id",
|
||||||
|
toDelete |> List.map (fun it -> [ "@id", Sql.string (CustomFeedId.toString it.Id) ])
|
||||||
|
if not (List.isEmpty toAddOrUpdate) then
|
||||||
|
"INSERT INTO web_log_feed (
|
||||||
|
id, web_log_id, source, path, podcast
|
||||||
|
) VALUES (
|
||||||
|
@id, @webLogId, @source, @path, @podcast
|
||||||
|
) ON CONFLICT (id) DO UPDATE
|
||||||
|
SET source = EXCLUDED.source,
|
||||||
|
path = EXCLUDED.path,
|
||||||
|
podcast = EXCLUDED.podcast",
|
||||||
|
toAddOrUpdate |> List.map (feedParams webLog.Id)
|
||||||
|
]
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMPLEMENTATION FUNCTIONS
|
||||||
|
|
||||||
|
/// Add a web log
|
||||||
|
let add webLog = backgroundTask {
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query
|
||||||
|
"INSERT INTO web_log (
|
||||||
|
id, name, slug, subtitle, default_page, posts_per_page, theme_id, url_base, time_zone, auto_htmx,
|
||||||
|
uploads, is_feed_enabled, feed_name, items_in_feed, is_category_enabled, is_tag_enabled, copyright
|
||||||
|
) VALUES (
|
||||||
|
@id, @name, @slug, @subtitle, @defaultPage, @postsPerPage, @themeId, @urlBase, @timeZone, @autoHtmx,
|
||||||
|
@uploads, @isFeedEnabled, @feedName, @itemsInFeed, @isCategoryEnabled, @isTagEnabled, @copyright
|
||||||
|
)"
|
||||||
|
|> Sql.parameters (webLogParams webLog)
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
do! updateCustomFeeds webLog
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve all web logs
|
||||||
|
let all () = backgroundTask {
|
||||||
|
let! webLogs =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM web_log"
|
||||||
|
|> Sql.executeAsync Map.toWebLog
|
||||||
|
let! feeds =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM web_log_feed"
|
||||||
|
|> Sql.executeAsync (fun row -> WebLogId (row.string "web_log_id"), toCustomFeed row)
|
||||||
|
return
|
||||||
|
webLogs
|
||||||
|
|> List.map (fun it ->
|
||||||
|
{ it with
|
||||||
|
Rss =
|
||||||
|
{ it.Rss with
|
||||||
|
CustomFeeds = feeds |> List.filter (fun (wlId, _) -> wlId = it.Id) |> List.map snd } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a web log by its ID
|
||||||
|
let delete webLogId = backgroundTask {
|
||||||
|
let subQuery table = $"(SELECT id FROM {table} WHERE web_log_id = @webLogId)"
|
||||||
|
let postSubQuery = subQuery "post"
|
||||||
|
let pageSubQuery = subQuery "page"
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"
|
||||||
|
DELETE FROM post_comment WHERE post_id IN {postSubQuery};
|
||||||
|
DELETE FROM post_revision WHERE post_id IN {postSubQuery};
|
||||||
|
DELETE FROM post_category WHERE post_id IN {postSubQuery};
|
||||||
|
DELETE FROM post WHERE web_log_id = @webLogId;
|
||||||
|
DELETE FROM page_revision WHERE page_id IN {pageSubQuery};
|
||||||
|
DELETE FROM page WHERE web_log_id = @webLogId;
|
||||||
|
DELETE FROM category WHERE web_log_id = @webLogId;
|
||||||
|
DELETE FROM tag_map WHERE web_log_id = @webLogId;
|
||||||
|
DELETE FROM upload WHERE web_log_id = @webLogId;
|
||||||
|
DELETE FROM web_log_user WHERE web_log_id = @webLogId;
|
||||||
|
DELETE FROM web_log_feed WHERE web_log_id = @webLogId;
|
||||||
|
DELETE FROM web_log WHERE id = @webLogId"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a web log by its host (URL base)
|
||||||
|
let findByHost url = backgroundTask {
|
||||||
|
let! webLog =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM web_log WHERE url_base = @urlBase"
|
||||||
|
|> Sql.parameters [ "@urlBase", Sql.string url ]
|
||||||
|
|> Sql.executeAsync Map.toWebLog
|
||||||
|
|> tryHead
|
||||||
|
if Option.isSome webLog then
|
||||||
|
let! withFeeds = appendCustomFeeds webLog.Value
|
||||||
|
return Some withFeeds
|
||||||
|
else return None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a web log by its ID
|
||||||
|
let findById webLogId = backgroundTask {
|
||||||
|
let! webLog =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM web_log WHERE id = @webLogId"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeAsync Map.toWebLog
|
||||||
|
|> tryHead
|
||||||
|
if Option.isSome webLog then
|
||||||
|
let! withFeeds = appendCustomFeeds webLog.Value
|
||||||
|
return Some withFeeds
|
||||||
|
else return None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update settings for a web log
|
||||||
|
let updateSettings webLog = backgroundTask {
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query
|
||||||
|
"UPDATE web_log
|
||||||
|
SET name = @name,
|
||||||
|
slug = @slug,
|
||||||
|
subtitle = @subtitle,
|
||||||
|
default_page = @defaultPage,
|
||||||
|
posts_per_page = @postsPerPage,
|
||||||
|
theme_id = @themeId,
|
||||||
|
url_base = @urlBase,
|
||||||
|
time_zone = @timeZone,
|
||||||
|
auto_htmx = @autoHtmx,
|
||||||
|
uploads = @uploads,
|
||||||
|
is_feed_enabled = @isFeedEnabled,
|
||||||
|
feed_name = @feedName,
|
||||||
|
items_in_feed = @itemsInFeed,
|
||||||
|
is_category_enabled = @isCategoryEnabled,
|
||||||
|
is_tag_enabled = @isTagEnabled,
|
||||||
|
copyright = @copyright
|
||||||
|
WHERE id = @id"
|
||||||
|
|> Sql.parameters (webLogParams webLog)
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update RSS options for a web log
|
||||||
|
let updateRssOptions (webLog : WebLog) = backgroundTask {
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query
|
||||||
|
"UPDATE web_log
|
||||||
|
SET is_feed_enabled = @isFeedEnabled,
|
||||||
|
feed_name = @feedName,
|
||||||
|
items_in_feed = @itemsInFeed,
|
||||||
|
is_category_enabled = @isCategoryEnabled,
|
||||||
|
is_tag_enabled = @isTagEnabled,
|
||||||
|
copyright = @copyright
|
||||||
|
WHERE id = @webLogId"
|
||||||
|
|> Sql.parameters (webLogIdParam webLog.Id :: rssParams webLog)
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
do! updateCustomFeeds webLog
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IWebLogData with
|
||||||
|
member _.Add webLog = add webLog
|
||||||
|
member _.All () = all ()
|
||||||
|
member _.Delete webLogId = delete webLogId
|
||||||
|
member _.FindByHost url = findByHost url
|
||||||
|
member _.FindById webLogId = findById webLogId
|
||||||
|
member _.UpdateSettings webLog = updateSettings webLog
|
||||||
|
member _.UpdateRssOptions webLog = updateRssOptions webLog
|
||||||
149
src/MyWebLog.Data/Postgres/PostgresWebLogUserData.fs
Normal file
149
src/MyWebLog.Data/Postgres/PostgresWebLogUserData.fs
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
namespace MyWebLog.Data.Postgres
|
||||||
|
|
||||||
|
open MyWebLog
|
||||||
|
open MyWebLog.Data
|
||||||
|
open Npgsql
|
||||||
|
open Npgsql.FSharp
|
||||||
|
|
||||||
|
/// PostgreSQL myWebLog user data implementation
|
||||||
|
type PostgresWebLogUserData (conn : NpgsqlConnection) =
|
||||||
|
|
||||||
|
/// The INSERT statement for a user
|
||||||
|
let userInsert =
|
||||||
|
"INSERT INTO web_log_user (
|
||||||
|
id, web_log_id, email, first_name, last_name, preferred_name, password_hash, url, access_level,
|
||||||
|
created_on, last_seen_on
|
||||||
|
) VALUES (
|
||||||
|
@id, @webLogId, @email, @firstName, @lastName, @preferredName, @passwordHash, @url, @accessLevel,
|
||||||
|
@createdOn, @lastSeenOn
|
||||||
|
)"
|
||||||
|
|
||||||
|
/// Parameters for saving web log users
|
||||||
|
let userParams (user : WebLogUser) = [
|
||||||
|
"@id", Sql.string (WebLogUserId.toString user.Id)
|
||||||
|
"@webLogId", Sql.string (WebLogId.toString user.WebLogId)
|
||||||
|
"@email", Sql.string user.Email
|
||||||
|
"@firstName", Sql.string user.FirstName
|
||||||
|
"@lastName", Sql.string user.LastName
|
||||||
|
"@preferredName", Sql.string user.PreferredName
|
||||||
|
"@passwordHash", Sql.string user.PasswordHash
|
||||||
|
"@url", Sql.stringOrNone user.Url
|
||||||
|
"@accessLevel", Sql.string (AccessLevel.toString user.AccessLevel)
|
||||||
|
typedParam "createdOn" user.CreatedOn
|
||||||
|
optParam "lastSeenOn" user.LastSeenOn
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Find a user by their ID for the given web log
|
||||||
|
let findById userId webLogId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM web_log_user WHERE id = @id AND web_log_id = @webLogId"
|
||||||
|
|> Sql.parameters [ "@id", Sql.string (WebLogUserId.toString userId); webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeAsync Map.toWebLogUser
|
||||||
|
|> tryHead
|
||||||
|
|
||||||
|
/// Delete a user if they have no posts or pages
|
||||||
|
let delete userId webLogId = backgroundTask {
|
||||||
|
match! findById userId webLogId with
|
||||||
|
| Some _ ->
|
||||||
|
let userParam = [ "@userId", Sql.string (WebLogUserId.toString userId) ]
|
||||||
|
let! isAuthor =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query
|
||||||
|
"SELECT ( EXISTS (SELECT 1 FROM page WHERE author_id = @userId
|
||||||
|
OR EXISTS (SELECT 1 FROM post WHERE author_id = @userId)) AS does_exist"
|
||||||
|
|> Sql.parameters userParam
|
||||||
|
|> Sql.executeRowAsync Map.toExists
|
||||||
|
if isAuthor then
|
||||||
|
return Error "User has pages or posts; cannot delete"
|
||||||
|
else
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "DELETE FROM web_log_user WHERE id = @userId"
|
||||||
|
|> Sql.parameters userParam
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
return Ok true
|
||||||
|
| None -> return Error "User does not exist"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a user by their e-mail address for the given web log
|
||||||
|
let findByEmail email webLogId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM web_log_user WHERE web_log_id = @webLogId AND email = @email"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId; "@email", Sql.string email ]
|
||||||
|
|> Sql.executeAsync Map.toWebLogUser
|
||||||
|
|> tryHead
|
||||||
|
|
||||||
|
/// Get all users for the given web log
|
||||||
|
let findByWebLog webLogId =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT * FROM web_log_user WHERE web_log_id = @webLogId ORDER BY LOWER(preferred_name)"
|
||||||
|
|> Sql.parameters [ webLogIdParam webLogId ]
|
||||||
|
|> Sql.executeAsync Map.toWebLogUser
|
||||||
|
|
||||||
|
/// Find the names of users by their IDs for the given web log
|
||||||
|
let findNames webLogId userIds = backgroundTask {
|
||||||
|
let idSql, idParams = inClause "AND id" "id" WebLogUserId.toString userIds
|
||||||
|
let! users =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"SELECT * FROM web_log_user WHERE web_log_id = @webLogId {idSql}"
|
||||||
|
|> Sql.parameters (webLogIdParam webLogId :: idParams)
|
||||||
|
|> Sql.executeAsync Map.toWebLogUser
|
||||||
|
return
|
||||||
|
users
|
||||||
|
|> List.map (fun u -> { Name = WebLogUserId.toString u.Id; Value = WebLogUser.displayName u })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore users from a backup
|
||||||
|
let restore users = backgroundTask {
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.executeTransactionAsync [
|
||||||
|
userInsert, users |> List.map userParams
|
||||||
|
]
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a user's last seen date/time to now
|
||||||
|
let setLastSeen userId webLogId = backgroundTask {
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "UPDATE web_log_user SET last_seen_on = @lastSeenOn WHERE id = @id AND web_log_id = @webLogId"
|
||||||
|
|> Sql.parameters
|
||||||
|
[ webLogIdParam webLogId
|
||||||
|
typedParam "lastSeenOn" (Noda.now ())
|
||||||
|
"@id", Sql.string (WebLogUserId.toString userId) ]
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save a user
|
||||||
|
let save user = backgroundTask {
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"
|
||||||
|
{userInsert} ON CONFLICT (id) DO UPDATE
|
||||||
|
SET email = @email,
|
||||||
|
first_name = @firstName,
|
||||||
|
last_name = @lastName,
|
||||||
|
preferred_name = @preferredName,
|
||||||
|
password_hash = @passwordHash,
|
||||||
|
url = @url,
|
||||||
|
access_level = @accessLevel,
|
||||||
|
created_on = @createdOn,
|
||||||
|
last_seen_on = @lastSeenOn"
|
||||||
|
|> Sql.parameters (userParams user)
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IWebLogUserData with
|
||||||
|
member _.Add user = save user
|
||||||
|
member _.Delete userId webLogId = delete userId webLogId
|
||||||
|
member _.FindByEmail email webLogId = findByEmail email webLogId
|
||||||
|
member _.FindById userId webLogId = findById userId webLogId
|
||||||
|
member _.FindByWebLog webLogId = findByWebLog webLogId
|
||||||
|
member _.FindNames webLogId userIds = findNames webLogId userIds
|
||||||
|
member _.Restore users = restore users
|
||||||
|
member _.SetLastSeen userId webLogId = setLastSeen userId webLogId
|
||||||
|
member _.Update user = save user
|
||||||
|
|
||||||
260
src/MyWebLog.Data/PostgresData.fs
Normal file
260
src/MyWebLog.Data/PostgresData.fs
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
namespace MyWebLog.Data
|
||||||
|
|
||||||
|
open Microsoft.Extensions.Logging
|
||||||
|
open MyWebLog.Data.Postgres
|
||||||
|
open Newtonsoft.Json
|
||||||
|
open Npgsql
|
||||||
|
open Npgsql.FSharp
|
||||||
|
|
||||||
|
/// Data implementation for PostgreSQL
|
||||||
|
type PostgresData (conn : NpgsqlConnection, log : ILogger<PostgresData>, ser : JsonSerializer) =
|
||||||
|
|
||||||
|
/// Create any needed tables
|
||||||
|
let ensureTables () = backgroundTask {
|
||||||
|
let _ = NpgsqlConnection.GlobalTypeMapper.UseNodaTime ()
|
||||||
|
|
||||||
|
let! tables =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT tablename FROM pg_tables WHERE schemaname = 'public'"
|
||||||
|
|> Sql.executeAsync (fun row -> row.string "tablename")
|
||||||
|
let needsTable table = not (List.contains table tables)
|
||||||
|
let mutable isNew = false
|
||||||
|
|
||||||
|
let sql = seq {
|
||||||
|
// Theme tables
|
||||||
|
if needsTable "theme" then
|
||||||
|
isNew <- true
|
||||||
|
"CREATE TABLE theme (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
version TEXT NOT NULL)"
|
||||||
|
if needsTable "theme_template" then
|
||||||
|
"CREATE TABLE theme_template (
|
||||||
|
theme_id TEXT NOT NULL REFERENCES theme (id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
template TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (theme_id, name))"
|
||||||
|
if needsTable "theme_asset" then
|
||||||
|
"CREATE TABLE theme_asset (
|
||||||
|
theme_id TEXT NOT NULL REFERENCES theme (id),
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
updated_on TIMESTAMPTZ NOT NULL,
|
||||||
|
data BYTEA NOT NULL,
|
||||||
|
PRIMARY KEY (theme_id, path))"
|
||||||
|
|
||||||
|
// Web log tables
|
||||||
|
if needsTable "web_log" then
|
||||||
|
"CREATE TABLE web_log (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
subtitle TEXT,
|
||||||
|
default_page TEXT NOT NULL,
|
||||||
|
posts_per_page INTEGER NOT NULL,
|
||||||
|
theme_id TEXT NOT NULL REFERENCES theme (id),
|
||||||
|
url_base TEXT NOT NULL,
|
||||||
|
time_zone TEXT NOT NULL,
|
||||||
|
auto_htmx BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
uploads TEXT NOT NULL,
|
||||||
|
is_feed_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
feed_name TEXT NOT NULL,
|
||||||
|
items_in_feed INTEGER,
|
||||||
|
is_category_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_tag_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
copyright TEXT)"
|
||||||
|
"CREATE INDEX web_log_theme_idx ON web_log (theme_id)"
|
||||||
|
if needsTable "web_log_feed" then
|
||||||
|
"CREATE TABLE web_log_feed (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
web_log_id TEXT NOT NULL REFERENCES web_log (id),
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
podcast JSONB)"
|
||||||
|
"CREATE INDEX web_log_feed_web_log_idx ON web_log_feed (web_log_id)"
|
||||||
|
|
||||||
|
// Category table
|
||||||
|
if needsTable "category" then
|
||||||
|
"CREATE TABLE category (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
web_log_id TEXT NOT NULL REFERENCES web_log (id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
parent_id TEXT)"
|
||||||
|
"CREATE INDEX category_web_log_idx ON category (web_log_id)"
|
||||||
|
|
||||||
|
// Web log user table
|
||||||
|
if needsTable "web_log_user" then
|
||||||
|
"CREATE TABLE web_log_user (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
web_log_id TEXT NOT NULL REFERENCES web_log (id),
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
first_name TEXT NOT NULL,
|
||||||
|
last_name TEXT NOT NULL,
|
||||||
|
preferred_name TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
url TEXT,
|
||||||
|
access_level TEXT NOT NULL,
|
||||||
|
created_on TIMESTAMPTZ NOT NULL,
|
||||||
|
last_seen_on TIMESTAMPTZ)"
|
||||||
|
"CREATE INDEX web_log_user_web_log_idx ON web_log_user (web_log_id)"
|
||||||
|
"CREATE INDEX web_log_user_email_idx ON web_log_user (web_log_id, email)"
|
||||||
|
|
||||||
|
// Page tables
|
||||||
|
if needsTable "page" then
|
||||||
|
"CREATE TABLE page (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
web_log_id TEXT NOT NULL REFERENCES web_log (id),
|
||||||
|
author_id TEXT NOT NULL REFERENCES web_log_user (id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
permalink TEXT NOT NULL,
|
||||||
|
prior_permalinks TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
published_on TIMESTAMPTZ NOT NULL,
|
||||||
|
updated_on TIMESTAMPTZ NOT NULL,
|
||||||
|
is_in_page_list BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
template TEXT,
|
||||||
|
page_text TEXT NOT NULL,
|
||||||
|
meta_items JSONB)"
|
||||||
|
"CREATE INDEX page_web_log_idx ON page (web_log_id)"
|
||||||
|
"CREATE INDEX page_author_idx ON page (author_id)"
|
||||||
|
"CREATE INDEX page_permalink_idx ON page (web_log_id, permalink)"
|
||||||
|
if needsTable "page_revision" then
|
||||||
|
"CREATE TABLE page_revision (
|
||||||
|
page_id TEXT NOT NULL REFERENCES page (id),
|
||||||
|
as_of TIMESTAMPTZ NOT NULL,
|
||||||
|
revision_text TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (page_id, as_of))"
|
||||||
|
|
||||||
|
// Post tables
|
||||||
|
if needsTable "post" then
|
||||||
|
"CREATE TABLE post (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
web_log_id TEXT NOT NULL REFERENCES web_log (id),
|
||||||
|
author_id TEXT NOT NULL REFERENCES web_log_user (id),
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
permalink TEXT NOT NULL,
|
||||||
|
prior_permalinks TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
published_on TIMESTAMPTZ,
|
||||||
|
updated_on TIMESTAMPTZ NOT NULL,
|
||||||
|
template TEXT,
|
||||||
|
post_text TEXT NOT NULL,
|
||||||
|
tags TEXT[],
|
||||||
|
meta_items JSONB,
|
||||||
|
episode JSONB)"
|
||||||
|
"CREATE INDEX post_web_log_idx ON post (web_log_id)"
|
||||||
|
"CREATE INDEX post_author_idx ON post (author_id)"
|
||||||
|
"CREATE INDEX post_status_idx ON post (web_log_id, status, updated_on)"
|
||||||
|
"CREATE INDEX post_permalink_idx ON post (web_log_id, permalink)"
|
||||||
|
if needsTable "post_category" then
|
||||||
|
"CREATE TABLE post_category (
|
||||||
|
post_id TEXT NOT NULL REFERENCES post (id),
|
||||||
|
category_id TEXT NOT NULL REFERENCES category (id),
|
||||||
|
PRIMARY KEY (post_id, category_id))"
|
||||||
|
"CREATE INDEX post_category_category_idx ON post_category (category_id)"
|
||||||
|
if needsTable "post_revision" then
|
||||||
|
"CREATE TABLE post_revision (
|
||||||
|
post_id TEXT NOT NULL REFERENCES post (id),
|
||||||
|
as_of TIMESTAMPTZ NOT NULL,
|
||||||
|
revision_text TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (post_id, as_of))"
|
||||||
|
if needsTable "post_comment" then
|
||||||
|
"CREATE TABLE post_comment (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
post_id TEXT NOT NULL REFERENCES post(id),
|
||||||
|
in_reply_to_id TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
url TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
posted_on TIMESTAMPTZ NOT NULL,
|
||||||
|
comment_text TEXT NOT NULL)"
|
||||||
|
"CREATE INDEX post_comment_post_idx ON post_comment (post_id)"
|
||||||
|
|
||||||
|
// Tag map table
|
||||||
|
if needsTable "tag_map" then
|
||||||
|
"CREATE TABLE tag_map (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
web_log_id TEXT NOT NULL REFERENCES web_log (id),
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
url_value TEXT NOT NULL)"
|
||||||
|
"CREATE INDEX tag_map_web_log_idx ON tag_map (web_log_id)"
|
||||||
|
|
||||||
|
// Uploaded file table
|
||||||
|
if needsTable "upload" then
|
||||||
|
"CREATE TABLE upload (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
web_log_id TEXT NOT NULL REFERENCES web_log (id),
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
updated_on TIMESTAMPTZ NOT NULL,
|
||||||
|
data BYTEA NOT NULL)"
|
||||||
|
"CREATE INDEX upload_web_log_idx ON upload (web_log_id)"
|
||||||
|
"CREATE INDEX upload_path_idx ON upload (web_log_id, path)"
|
||||||
|
|
||||||
|
// Database version table
|
||||||
|
if needsTable "db_version" then
|
||||||
|
"CREATE TABLE db_version (id TEXT NOT NULL PRIMARY KEY)"
|
||||||
|
$"INSERT INTO db_version VALUES ('{Utils.currentDbVersion}')"
|
||||||
|
}
|
||||||
|
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.executeTransactionAsync
|
||||||
|
(sql
|
||||||
|
|> Seq.map (fun s ->
|
||||||
|
let parts = s.Split ' '
|
||||||
|
if parts[1].ToLowerInvariant () = "table" then
|
||||||
|
log.LogInformation $"Creating {parts[2]} table..."
|
||||||
|
s, [ [] ])
|
||||||
|
|> List.ofSeq)
|
||||||
|
|> Async.AwaitTask
|
||||||
|
|> Async.RunSynchronously
|
||||||
|
|> ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a specific database version
|
||||||
|
let setDbVersion version = backgroundTask {
|
||||||
|
let! _ =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query $"DELETE FROM db_version; INSERT INTO db_version VALUES ('%s{version}')"
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Do required data migration between versions
|
||||||
|
let migrate version = backgroundTask {
|
||||||
|
match version with
|
||||||
|
| Some "v2-rc2" -> ()
|
||||||
|
// Future versions will be inserted here
|
||||||
|
| Some _
|
||||||
|
| None ->
|
||||||
|
log.LogWarning $"Unknown database version; assuming {Utils.currentDbVersion}"
|
||||||
|
do! setDbVersion Utils.currentDbVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IData with
|
||||||
|
|
||||||
|
member _.Category = PostgresCategoryData conn
|
||||||
|
member _.Page = PostgresPageData (conn, ser)
|
||||||
|
member _.Post = PostgresPostData (conn, ser)
|
||||||
|
member _.TagMap = PostgresTagMapData conn
|
||||||
|
member _.Theme = PostgresThemeData conn
|
||||||
|
member _.ThemeAsset = PostgresThemeAssetData conn
|
||||||
|
member _.Upload = PostgresUploadData conn
|
||||||
|
member _.WebLog = PostgresWebLogData (conn, ser)
|
||||||
|
member _.WebLogUser = PostgresWebLogUserData conn
|
||||||
|
|
||||||
|
member _.Serializer = ser
|
||||||
|
|
||||||
|
member _.StartUp () = backgroundTask {
|
||||||
|
do! ensureTables ()
|
||||||
|
|
||||||
|
let! version =
|
||||||
|
Sql.existingConnection conn
|
||||||
|
|> Sql.query "SELECT id FROM db_version"
|
||||||
|
|> Sql.executeAsync (fun row -> row.string "id")
|
||||||
|
|> tryHead
|
||||||
|
match version with
|
||||||
|
| Some v when v = Utils.currentDbVersion -> ()
|
||||||
|
| Some _
|
||||||
|
| None -> do! migrate version
|
||||||
|
}
|
||||||
@@ -17,7 +17,10 @@ module private RethinkHelpers =
|
|||||||
|
|
||||||
/// The comment table
|
/// The comment table
|
||||||
let Comment = "Comment"
|
let Comment = "Comment"
|
||||||
|
|
||||||
|
/// The database version table
|
||||||
|
let DbVersion = "DbVersion"
|
||||||
|
|
||||||
/// The page table
|
/// The page table
|
||||||
let Page = "Page"
|
let Page = "Page"
|
||||||
|
|
||||||
@@ -43,7 +46,7 @@ module private RethinkHelpers =
|
|||||||
let WebLogUser = "WebLogUser"
|
let WebLogUser = "WebLogUser"
|
||||||
|
|
||||||
/// A list of all tables
|
/// A list of all tables
|
||||||
let all = [ Category; Comment; Page; Post; TagMap; Theme; ThemeAsset; Upload; WebLog; WebLogUser ]
|
let all = [ Category; Comment; DbVersion; Page; Post; TagMap; Theme; ThemeAsset; Upload; WebLog; WebLogUser ]
|
||||||
|
|
||||||
|
|
||||||
/// Index names for indexes not on a data item's name
|
/// Index names for indexes not on a data item's name
|
||||||
@@ -96,6 +99,10 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
|
|||||||
let keyPrefix = $"^{ThemeId.toString themeId}/"
|
let keyPrefix = $"^{ThemeId.toString themeId}/"
|
||||||
fun (row : Ast.ReqlExpr) -> row[nameof ThemeAsset.empty.Id].Match keyPrefix :> obj
|
fun (row : Ast.ReqlExpr) -> row[nameof ThemeAsset.empty.Id].Match keyPrefix :> obj
|
||||||
|
|
||||||
|
/// Function to exclude template text from themes
|
||||||
|
let withoutTemplateText (row : Ast.ReqlExpr) : obj =
|
||||||
|
{| Templates = row[nameof Theme.empty.Templates].Without [| nameof ThemeTemplate.empty.Text |] |}
|
||||||
|
|
||||||
/// Ensure field indexes exist, as well as special indexes for selected tables
|
/// Ensure field indexes exist, as well as special indexes for selected tables
|
||||||
let ensureIndexes table fields = backgroundTask {
|
let ensureIndexes table fields = backgroundTask {
|
||||||
let! indexes = rethink<string list> { withTable table; indexList; result; withRetryOnce conn }
|
let! indexes = rethink<string list> { withTable table; indexList; result; withRetryOnce conn }
|
||||||
@@ -176,6 +183,49 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
|
|||||||
/// The batch size for restoration methods
|
/// The batch size for restoration methods
|
||||||
let restoreBatchSize = 100
|
let restoreBatchSize = 100
|
||||||
|
|
||||||
|
/// Delete assets for the given theme ID
|
||||||
|
let deleteAssetsByTheme themeId = rethink {
|
||||||
|
withTable Table.ThemeAsset
|
||||||
|
filter (matchAssetByThemeId themeId)
|
||||||
|
delete
|
||||||
|
write; withRetryDefault; ignoreResult conn
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a specific database version
|
||||||
|
let setDbVersion (version : string) = backgroundTask {
|
||||||
|
do! rethink {
|
||||||
|
withTable Table.DbVersion
|
||||||
|
delete
|
||||||
|
write; withRetryOnce; ignoreResult conn
|
||||||
|
}
|
||||||
|
do! rethink {
|
||||||
|
withTable Table.DbVersion
|
||||||
|
insert {| Id = version |}
|
||||||
|
write; withRetryOnce; ignoreResult conn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Migrate from v2-rc1 to v2-rc2
|
||||||
|
let migrateV2Rc1ToV2Rc2 () = backgroundTask {
|
||||||
|
let logStep = Utils.logMigrationStep log "v2-rc1 to v2-rc2"
|
||||||
|
logStep "**IMPORTANT**"
|
||||||
|
logStep "See release notes about required backup/restoration for RethinkDB."
|
||||||
|
logStep "If there is an error immediately below this message, this is why."
|
||||||
|
logStep "Setting database version to v2-rc2"
|
||||||
|
do! setDbVersion "v2-rc2"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Migrate data between versions
|
||||||
|
let migrate version = backgroundTask {
|
||||||
|
match version with
|
||||||
|
| Some v when v = "v2-rc2" -> ()
|
||||||
|
| Some v when v = "v2-rc1" -> do! migrateV2Rc1ToV2Rc2 ()
|
||||||
|
| Some _
|
||||||
|
| None ->
|
||||||
|
log.LogWarning $"Unknown database version; assuming {Utils.currentDbVersion}"
|
||||||
|
do! setDbVersion Utils.currentDbVersion
|
||||||
|
}
|
||||||
|
|
||||||
/// The connection for this instance
|
/// The connection for this instance
|
||||||
member _.Conn = conn
|
member _.Conn = conn
|
||||||
|
|
||||||
@@ -262,7 +312,21 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
|
|||||||
|
|
||||||
member this.Delete catId webLogId = backgroundTask {
|
member this.Delete catId webLogId = backgroundTask {
|
||||||
match! this.FindById catId webLogId with
|
match! this.FindById catId webLogId with
|
||||||
| Some _ ->
|
| Some cat ->
|
||||||
|
// Reassign any children to the category's parent category
|
||||||
|
let! children = rethink<int> {
|
||||||
|
withTable Table.Category
|
||||||
|
filter (nameof Category.empty.ParentId) catId
|
||||||
|
count
|
||||||
|
result; withRetryDefault conn
|
||||||
|
}
|
||||||
|
if children > 0 then
|
||||||
|
do! rethink {
|
||||||
|
withTable Table.Category
|
||||||
|
filter (nameof Category.empty.ParentId) catId
|
||||||
|
update [ nameof Category.empty.ParentId, cat.ParentId :> obj ]
|
||||||
|
write; withRetryDefault; ignoreResult conn
|
||||||
|
}
|
||||||
// Delete the category off all posts where it is assigned
|
// Delete the category off all posts where it is assigned
|
||||||
do! rethink {
|
do! rethink {
|
||||||
withTable Table.Post
|
withTable Table.Post
|
||||||
@@ -279,8 +343,8 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
|
|||||||
delete
|
delete
|
||||||
write; withRetryDefault; ignoreResult conn
|
write; withRetryDefault; ignoreResult conn
|
||||||
}
|
}
|
||||||
return true
|
return if children = 0 then CategoryDeleted else ReassignedChildCategories
|
||||||
| None -> return false
|
| None -> return CategoryNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
member _.Restore cats = backgroundTask {
|
member _.Restore cats = backgroundTask {
|
||||||
@@ -711,11 +775,21 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
|
|||||||
member _.All () = rethink<Theme list> {
|
member _.All () = rethink<Theme list> {
|
||||||
withTable Table.Theme
|
withTable Table.Theme
|
||||||
filter (fun row -> row[nameof Theme.empty.Id].Ne "admin" :> obj)
|
filter (fun row -> row[nameof Theme.empty.Id].Ne "admin" :> obj)
|
||||||
without [ nameof Theme.empty.Templates ]
|
merge withoutTemplateText
|
||||||
orderBy (nameof Theme.empty.Id)
|
orderBy (nameof Theme.empty.Id)
|
||||||
result; withRetryDefault conn
|
result; withRetryDefault conn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
member _.Exists themeId = backgroundTask {
|
||||||
|
let! count = rethink<int> {
|
||||||
|
withTable Table.Theme
|
||||||
|
filter (nameof Theme.empty.Id) themeId
|
||||||
|
count
|
||||||
|
result; withRetryDefault conn
|
||||||
|
}
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
|
|
||||||
member _.FindById themeId = rethink<Theme> {
|
member _.FindById themeId = rethink<Theme> {
|
||||||
withTable Table.Theme
|
withTable Table.Theme
|
||||||
get themeId
|
get themeId
|
||||||
@@ -725,12 +799,24 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
|
|||||||
member _.FindByIdWithoutText themeId = rethink<Theme> {
|
member _.FindByIdWithoutText themeId = rethink<Theme> {
|
||||||
withTable Table.Theme
|
withTable Table.Theme
|
||||||
get themeId
|
get themeId
|
||||||
merge (fun row ->
|
merge withoutTemplateText
|
||||||
{| Templates = row[nameof Theme.empty.Templates].Without [| nameof ThemeTemplate.empty.Text |]
|
|
||||||
|})
|
|
||||||
resultOption; withRetryOptionDefault conn
|
resultOption; withRetryOptionDefault conn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
member this.Delete themeId = backgroundTask {
|
||||||
|
match! this.FindByIdWithoutText themeId with
|
||||||
|
| Some _ ->
|
||||||
|
do! deleteAssetsByTheme themeId
|
||||||
|
do! rethink {
|
||||||
|
withTable Table.Theme
|
||||||
|
get themeId
|
||||||
|
delete
|
||||||
|
write; withRetryDefault; ignoreResult conn
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
| None -> return false
|
||||||
|
}
|
||||||
|
|
||||||
member _.Save theme = rethink {
|
member _.Save theme = rethink {
|
||||||
withTable Table.Theme
|
withTable Table.Theme
|
||||||
get theme.Id
|
get theme.Id
|
||||||
@@ -748,12 +834,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
|
|||||||
result; withRetryDefault conn
|
result; withRetryDefault conn
|
||||||
}
|
}
|
||||||
|
|
||||||
member _.DeleteByTheme themeId = rethink {
|
member _.DeleteByTheme themeId = deleteAssetsByTheme themeId
|
||||||
withTable Table.ThemeAsset
|
|
||||||
filter (matchAssetByThemeId themeId)
|
|
||||||
delete
|
|
||||||
write; withRetryDefault; ignoreResult conn
|
|
||||||
}
|
|
||||||
|
|
||||||
member _.FindById assetId = rethink<ThemeAsset> {
|
member _.FindById assetId = rethink<ThemeAsset> {
|
||||||
withTable Table.ThemeAsset
|
withTable Table.ThemeAsset
|
||||||
@@ -1036,7 +1117,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
|
|||||||
do! rethink {
|
do! rethink {
|
||||||
withTable Table.WebLogUser
|
withTable Table.WebLogUser
|
||||||
get userId
|
get userId
|
||||||
update [ nameof WebLogUser.empty.LastSeenOn, DateTime.UtcNow :> obj ]
|
update [ nameof WebLogUser.empty.LastSeenOn, Noda.now () :> obj ]
|
||||||
write; withRetryOnce; ignoreResult conn
|
write; withRetryOnce; ignoreResult conn
|
||||||
}
|
}
|
||||||
| None -> ()
|
| None -> ()
|
||||||
@@ -1051,7 +1132,6 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
|
|||||||
nameof user.LastName, user.LastName
|
nameof user.LastName, user.LastName
|
||||||
nameof user.PreferredName, user.PreferredName
|
nameof user.PreferredName, user.PreferredName
|
||||||
nameof user.PasswordHash, user.PasswordHash
|
nameof user.PasswordHash, user.PasswordHash
|
||||||
nameof user.Salt, user.Salt
|
|
||||||
nameof user.Url, user.Url
|
nameof user.Url, user.Url
|
||||||
nameof user.AccessLevel, user.AccessLevel
|
nameof user.AccessLevel, user.AccessLevel
|
||||||
]
|
]
|
||||||
@@ -1059,6 +1139,9 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
member _.Serializer =
|
||||||
|
Net.Converter.Serializer
|
||||||
|
|
||||||
member _.StartUp () = backgroundTask {
|
member _.StartUp () = backgroundTask {
|
||||||
let! dbs = rethink<string list> { dbList; result; withRetryOnce conn }
|
let! dbs = rethink<string list> { dbList; result; withRetryOnce conn }
|
||||||
if not (dbs |> List.contains config.Database) then
|
if not (dbs |> List.contains config.Database) then
|
||||||
@@ -1071,6 +1154,14 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
|
|||||||
log.LogInformation $"Creating table {tbl}..."
|
log.LogInformation $"Creating table {tbl}..."
|
||||||
do! rethink { tableCreate tbl [ PrimaryKey "Id" ]; write; withRetryOnce; ignoreResult conn }
|
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
|
||||||
|
do! rethink {
|
||||||
|
withTable Table.DbVersion
|
||||||
|
insert {| Id = "v2-rc1" |}
|
||||||
|
write; withRetryOnce; ignoreResult conn
|
||||||
|
}
|
||||||
|
|
||||||
do! ensureIndexes Table.Category [ nameof Category.empty.WebLogId ]
|
do! ensureIndexes Table.Category [ nameof Category.empty.WebLogId ]
|
||||||
do! ensureIndexes Table.Comment [ nameof Comment.empty.PostId ]
|
do! ensureIndexes Table.Comment [ nameof Comment.empty.PostId ]
|
||||||
do! ensureIndexes Table.Page [ nameof Page.empty.WebLogId; nameof Page.empty.AuthorId ]
|
do! ensureIndexes Table.Page [ nameof Page.empty.WebLogId; nameof Page.empty.AuthorId ]
|
||||||
@@ -1079,4 +1170,13 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
|
|||||||
do! ensureIndexes Table.Upload []
|
do! ensureIndexes Table.Upload []
|
||||||
do! ensureIndexes Table.WebLog [ nameof WebLog.empty.UrlBase ]
|
do! ensureIndexes Table.WebLog [ nameof WebLog.empty.UrlBase ]
|
||||||
do! ensureIndexes Table.WebLogUser [ nameof WebLogUser.empty.WebLogId ]
|
do! ensureIndexes Table.WebLogUser [ nameof WebLogUser.empty.WebLogId ]
|
||||||
|
|
||||||
|
let! version = rethink<{| Id : string |} list> {
|
||||||
|
withTable Table.DbVersion
|
||||||
|
limit 1
|
||||||
|
result; withRetryOnce conn
|
||||||
|
}
|
||||||
|
match List.tryHead version with
|
||||||
|
| Some v when v.Id = "v2-rc2" -> ()
|
||||||
|
| it -> do! migrate (it |> Option.map (fun x -> x.Id))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ module MyWebLog.Data.SQLite.Helpers
|
|||||||
open System
|
open System
|
||||||
open Microsoft.Data.Sqlite
|
open Microsoft.Data.Sqlite
|
||||||
open MyWebLog
|
open MyWebLog
|
||||||
|
open MyWebLog.Data
|
||||||
|
open NodaTime.Text
|
||||||
|
|
||||||
/// Run a command that returns a count
|
/// Run a command that returns a count
|
||||||
let count (cmd : SqliteCommand) = backgroundTask {
|
let count (cmd : SqliteCommand) = backgroundTask {
|
||||||
@@ -12,23 +14,6 @@ let count (cmd : SqliteCommand) = backgroundTask {
|
|||||||
return int (it :?> int64)
|
return int (it :?> int64)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get lists of items removed from and added to the given lists
|
|
||||||
let diffLists<'T, 'U when 'U : equality> oldItems newItems (f : 'T -> 'U) =
|
|
||||||
let diff compList = fun item -> not (compList |> List.exists (fun other -> f item = f other))
|
|
||||||
List.filter (diff newItems) oldItems, List.filter (diff oldItems) newItems
|
|
||||||
|
|
||||||
/// Find meta items added and removed
|
|
||||||
let diffMetaItems (oldItems : MetaItem list) newItems =
|
|
||||||
diffLists oldItems newItems (fun item -> $"{item.Name}|{item.Value}")
|
|
||||||
|
|
||||||
/// Find the permalinks added and removed
|
|
||||||
let diffPermalinks oldLinks newLinks =
|
|
||||||
diffLists oldLinks newLinks Permalink.toString
|
|
||||||
|
|
||||||
/// Find the revisions added and removed
|
|
||||||
let diffRevisions oldRevs newRevs =
|
|
||||||
diffLists oldRevs newRevs (fun (rev : Revision) -> $"{rev.AsOf.Ticks}|{MarkupText.toString rev.Text}")
|
|
||||||
|
|
||||||
/// Create a list of items from the given data reader
|
/// Create a list of items from the given data reader
|
||||||
let toList<'T> (it : SqliteDataReader -> 'T) (rdr : SqliteDataReader) =
|
let toList<'T> (it : SqliteDataReader -> 'T) (rdr : SqliteDataReader) =
|
||||||
seq { while rdr.Read () do it rdr }
|
seq { while rdr.Read () do it rdr }
|
||||||
@@ -47,6 +32,42 @@ let write (cmd : SqliteCommand) = backgroundTask {
|
|||||||
()
|
()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add a possibly-missing parameter, substituting null for None
|
||||||
|
let maybe<'T> (it : 'T option) : obj = match it with Some x -> x :> obj | None -> DBNull.Value
|
||||||
|
|
||||||
|
/// Create a value for a Duration
|
||||||
|
let durationParam =
|
||||||
|
DurationPattern.Roundtrip.Format
|
||||||
|
|
||||||
|
/// Create a value for an Instant
|
||||||
|
let instantParam =
|
||||||
|
InstantPattern.General.Format
|
||||||
|
|
||||||
|
/// Create an optional value for a Duration
|
||||||
|
let maybeDuration =
|
||||||
|
Option.map durationParam >> maybe
|
||||||
|
|
||||||
|
/// Create an optional value for an Instant
|
||||||
|
let maybeInstant =
|
||||||
|
Option.map instantParam >> maybe
|
||||||
|
|
||||||
|
/// 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
|
/// Functions to map domain items from a data reader
|
||||||
module Map =
|
module Map =
|
||||||
|
|
||||||
@@ -73,6 +94,26 @@ module Map =
|
|||||||
/// Get a string value from a data reader
|
/// Get a string value from a data reader
|
||||||
let getString col (rdr : SqliteDataReader) = rdr.GetString (rdr.GetOrdinal col)
|
let getString col (rdr : SqliteDataReader) = rdr.GetString (rdr.GetOrdinal col)
|
||||||
|
|
||||||
|
/// Parse a Duration from the given value
|
||||||
|
let parseDuration value =
|
||||||
|
match DurationPattern.Roundtrip.Parse value with
|
||||||
|
| it when it.Success -> it.Value
|
||||||
|
| it -> raise it.Exception
|
||||||
|
|
||||||
|
/// Get a Duration value from a data reader
|
||||||
|
let getDuration col rdr =
|
||||||
|
getString col rdr |> parseDuration
|
||||||
|
|
||||||
|
/// 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
|
/// Get a timespan value from a data reader
|
||||||
let getTimeSpan col (rdr : SqliteDataReader) = rdr.GetTimeSpan (rdr.GetOrdinal col)
|
let getTimeSpan col (rdr : SqliteDataReader) = rdr.GetTimeSpan (rdr.GetOrdinal col)
|
||||||
|
|
||||||
@@ -96,6 +137,14 @@ module Map =
|
|||||||
let tryString col (rdr : SqliteDataReader) =
|
let tryString col (rdr : SqliteDataReader) =
|
||||||
if rdr.IsDBNull (rdr.GetOrdinal col) then None else Some (getString col rdr)
|
if rdr.IsDBNull (rdr.GetOrdinal col) then None else Some (getString col rdr)
|
||||||
|
|
||||||
|
/// Get a possibly null Duration value from a data reader
|
||||||
|
let tryDuration col rdr =
|
||||||
|
tryString col rdr |> Option.map parseDuration
|
||||||
|
|
||||||
|
/// Get a possibly null Instant value from a data reader
|
||||||
|
let tryInstant col rdr =
|
||||||
|
tryString col rdr |> Option.map parseInstant
|
||||||
|
|
||||||
/// Get a possibly null timespan value from a data reader
|
/// Get a possibly null timespan value from a data reader
|
||||||
let tryTimeSpan col (rdr : SqliteDataReader) =
|
let tryTimeSpan col (rdr : SqliteDataReader) =
|
||||||
if rdr.IsDBNull (rdr.GetOrdinal col) then None else Some (getTimeSpan col rdr)
|
if rdr.IsDBNull (rdr.GetOrdinal col) then None else Some (getTimeSpan col rdr)
|
||||||
@@ -114,100 +163,57 @@ module Map =
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a custom feed from the current row in the given data reader
|
/// Create a custom feed from the current row in the given data reader
|
||||||
let toCustomFeed rdr : CustomFeed =
|
let toCustomFeed ser rdr : CustomFeed =
|
||||||
{ Id = getString "id" rdr |> CustomFeedId
|
{ Id = getString "id" rdr |> CustomFeedId
|
||||||
Source = getString "source" rdr |> CustomFeedSource.parse
|
Source = getString "source" rdr |> CustomFeedSource.parse
|
||||||
Path = getString "path" rdr |> Permalink
|
Path = getString "path" rdr |> Permalink
|
||||||
Podcast =
|
Podcast = tryString "podcast" rdr |> Option.map (Utils.deserialize ser)
|
||||||
if rdr.IsDBNull (rdr.GetOrdinal "title") then
|
|
||||||
None
|
|
||||||
else
|
|
||||||
Some {
|
|
||||||
Title = getString "title" rdr
|
|
||||||
Subtitle = tryString "subtitle" rdr
|
|
||||||
ItemsInFeed = getInt "items_in_feed" rdr
|
|
||||||
Summary = getString "summary" rdr
|
|
||||||
DisplayedAuthor = getString "displayed_author" rdr
|
|
||||||
Email = getString "email" rdr
|
|
||||||
ImageUrl = getString "image_url" rdr |> Permalink
|
|
||||||
AppleCategory = getString "apple_category" rdr
|
|
||||||
AppleSubcategory = tryString "apple_subcategory" rdr
|
|
||||||
Explicit = getString "explicit" rdr |> ExplicitRating.parse
|
|
||||||
DefaultMediaType = tryString "default_media_type" rdr
|
|
||||||
MediaBaseUrl = tryString "media_base_url" rdr
|
|
||||||
PodcastGuid = tryGuid "podcast_guid" rdr
|
|
||||||
FundingUrl = tryString "funding_url" rdr
|
|
||||||
FundingText = tryString "funding_text" rdr
|
|
||||||
Medium = tryString "medium" rdr |> Option.map PodcastMedium.parse
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a meta item from the current row in the given data reader
|
|
||||||
let toMetaItem rdr : MetaItem =
|
|
||||||
{ Name = getString "name" rdr
|
|
||||||
Value = getString "value" rdr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a permalink from the current row in the given data reader
|
/// Create a permalink from the current row in the given data reader
|
||||||
let toPermalink rdr = getString "permalink" rdr |> Permalink
|
let toPermalink rdr = getString "permalink" rdr |> Permalink
|
||||||
|
|
||||||
/// Create a page from the current row in the given data reader
|
/// Create a page from the current row in the given data reader
|
||||||
let toPage rdr : Page =
|
let toPage ser rdr : Page =
|
||||||
{ Page.empty with
|
{ Page.empty with
|
||||||
Id = getString "id" rdr |> PageId
|
Id = getString "id" rdr |> PageId
|
||||||
WebLogId = getString "web_log_id" rdr |> WebLogId
|
WebLogId = getString "web_log_id" rdr |> WebLogId
|
||||||
AuthorId = getString "author_id" rdr |> WebLogUserId
|
AuthorId = getString "author_id" rdr |> WebLogUserId
|
||||||
Title = getString "title" rdr
|
Title = getString "title" rdr
|
||||||
Permalink = toPermalink rdr
|
Permalink = toPermalink rdr
|
||||||
PublishedOn = getDateTime "published_on" rdr
|
PublishedOn = getInstant "published_on" rdr
|
||||||
UpdatedOn = getDateTime "updated_on" rdr
|
UpdatedOn = getInstant "updated_on" rdr
|
||||||
IsInPageList = getBoolean "is_in_page_list" rdr
|
IsInPageList = getBoolean "is_in_page_list" rdr
|
||||||
Template = tryString "template" rdr
|
Template = tryString "template" rdr
|
||||||
Text = getString "page_text" rdr
|
Text = getString "page_text" rdr
|
||||||
|
Metadata = tryString "meta_items" rdr
|
||||||
|
|> Option.map (Utils.deserialize ser)
|
||||||
|
|> Option.defaultValue []
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a post from the current row in the given data reader
|
/// Create a post from the current row in the given data reader
|
||||||
let toPost rdr : Post =
|
let toPost ser rdr : Post =
|
||||||
{ Post.empty with
|
{ Post.empty with
|
||||||
Id = getString "id" rdr |> PostId
|
Id = getString "id" rdr |> PostId
|
||||||
WebLogId = getString "web_log_id" rdr |> WebLogId
|
WebLogId = getString "web_log_id" rdr |> WebLogId
|
||||||
AuthorId = getString "author_id" rdr |> WebLogUserId
|
AuthorId = getString "author_id" rdr |> WebLogUserId
|
||||||
Status = getString "status" rdr |> PostStatus.parse
|
Status = getString "status" rdr |> PostStatus.parse
|
||||||
Title = getString "title" rdr
|
Title = getString "title" rdr
|
||||||
Permalink = toPermalink rdr
|
Permalink = toPermalink rdr
|
||||||
PublishedOn = tryDateTime "published_on" rdr
|
PublishedOn = tryInstant "published_on" rdr
|
||||||
UpdatedOn = getDateTime "updated_on" rdr
|
UpdatedOn = getInstant "updated_on" rdr
|
||||||
Template = tryString "template" rdr
|
Template = tryString "template" rdr
|
||||||
Text = getString "post_text" rdr
|
Text = getString "post_text" rdr
|
||||||
Episode =
|
Episode = tryString "episode" rdr |> Option.map (Utils.deserialize ser)
|
||||||
match tryString "media" rdr with
|
Metadata = tryString "meta_items" rdr
|
||||||
| Some media ->
|
|> Option.map (Utils.deserialize ser)
|
||||||
Some {
|
|> Option.defaultValue []
|
||||||
Media = media
|
|
||||||
Length = getLong "length" rdr
|
|
||||||
Duration = tryTimeSpan "duration" rdr
|
|
||||||
MediaType = tryString "media_type" rdr
|
|
||||||
ImageUrl = tryString "image_url" rdr
|
|
||||||
Subtitle = tryString "subtitle" rdr
|
|
||||||
Explicit = tryString "explicit" rdr |> Option.map ExplicitRating.parse
|
|
||||||
ChapterFile = tryString "chapter_file" rdr
|
|
||||||
ChapterType = tryString "chapter_type" rdr
|
|
||||||
TranscriptUrl = tryString "transcript_url" rdr
|
|
||||||
TranscriptType = tryString "transcript_type" rdr
|
|
||||||
TranscriptLang = tryString "transcript_lang" rdr
|
|
||||||
TranscriptCaptions = tryBoolean "transcript_captions" rdr
|
|
||||||
SeasonNumber = tryInt "season_number" rdr
|
|
||||||
SeasonDescription = tryString "season_description" rdr
|
|
||||||
EpisodeNumber = tryString "episode_number" rdr |> Option.map Double.Parse
|
|
||||||
EpisodeDescription = tryString "episode_description" rdr
|
|
||||||
}
|
|
||||||
| None -> None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a revision from the current row in the given data reader
|
/// Create a revision from the current row in the given data reader
|
||||||
let toRevision rdr : Revision =
|
let toRevision rdr : Revision =
|
||||||
{ AsOf = getDateTime "as_of" rdr
|
{ AsOf = getInstant "as_of" rdr
|
||||||
Text = getString "revision_text" rdr |> MarkupText.parse
|
Text = getString "revision_text" rdr |> MarkupText.parse
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a tag mapping from the current row in the given data reader
|
/// Create a tag mapping from the current row in the given data reader
|
||||||
@@ -237,14 +243,14 @@ module Map =
|
|||||||
else
|
else
|
||||||
[||]
|
[||]
|
||||||
{ Id = ThemeAssetId (ThemeId (getString "theme_id" rdr), getString "path" rdr)
|
{ Id = ThemeAssetId (ThemeId (getString "theme_id" rdr), getString "path" rdr)
|
||||||
UpdatedOn = getDateTime "updated_on" rdr
|
UpdatedOn = getInstant "updated_on" rdr
|
||||||
Data = assetData
|
Data = assetData
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a theme template from the current row in the given data reader
|
/// Create a theme template from the current row in the given data reader
|
||||||
let toThemeTemplate rdr : ThemeTemplate =
|
let toThemeTemplate includeText rdr : ThemeTemplate =
|
||||||
{ Name = getString "name" rdr
|
{ Name = getString "name" rdr
|
||||||
Text = getString "template" rdr
|
Text = if includeText then getString "template" rdr else ""
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an uploaded file from the current row in the given data reader
|
/// Create an uploaded file from the current row in the given data reader
|
||||||
@@ -257,10 +263,10 @@ module Map =
|
|||||||
dataStream.ToArray ()
|
dataStream.ToArray ()
|
||||||
else
|
else
|
||||||
[||]
|
[||]
|
||||||
{ Id = getString "id" rdr |> UploadId
|
{ Id = getString "id" rdr |> UploadId
|
||||||
WebLogId = getString "web_log_id" rdr |> WebLogId
|
WebLogId = getString "web_log_id" rdr |> WebLogId
|
||||||
Path = getString "path" rdr |> Permalink
|
Path = getString "path" rdr |> Permalink
|
||||||
UpdatedOn = getDateTime "updated_on" rdr
|
UpdatedOn = getInstant "updated_on" rdr
|
||||||
Data = data
|
Data = data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,23 +296,19 @@ module Map =
|
|||||||
|
|
||||||
/// Create a web log user from the current row in the given data reader
|
/// Create a web log user from the current row in the given data reader
|
||||||
let toWebLogUser rdr : WebLogUser =
|
let toWebLogUser rdr : WebLogUser =
|
||||||
{ Id = getString "id" rdr |> WebLogUserId
|
{ Id = getString "id" rdr |> WebLogUserId
|
||||||
WebLogId = getString "web_log_id" rdr |> WebLogId
|
WebLogId = getString "web_log_id" rdr |> WebLogId
|
||||||
Email = getString "email" rdr
|
Email = getString "email" rdr
|
||||||
FirstName = getString "first_name" rdr
|
FirstName = getString "first_name" rdr
|
||||||
LastName = getString "last_name" rdr
|
LastName = getString "last_name" rdr
|
||||||
PreferredName = getString "preferred_name" rdr
|
PreferredName = getString "preferred_name" rdr
|
||||||
PasswordHash = getString "password_hash" rdr
|
PasswordHash = getString "password_hash" rdr
|
||||||
Salt = getGuid "salt" rdr
|
Url = tryString "url" rdr
|
||||||
Url = tryString "url" rdr
|
AccessLevel = getString "access_level" rdr |> AccessLevel.parse
|
||||||
AccessLevel = getString "access_level" rdr |> AccessLevel.parse
|
CreatedOn = getInstant "created_on" rdr
|
||||||
CreatedOn = getDateTime "created_on" rdr
|
LastSeenOn = tryInstant "last_seen_on" rdr
|
||||||
LastSeenOn = tryDateTime "last_seen_on" rdr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a possibly-missing parameter, substituting null for None
|
|
||||||
let maybe<'T> (it : 'T option) : obj = match it with Some x -> x :> obj | None -> DBNull.Value
|
|
||||||
|
|
||||||
/// Add a web log ID parameter
|
/// Add a web log ID parameter
|
||||||
let addWebLogId (cmd : SqliteCommand) webLogId =
|
let addWebLogId (cmd : SqliteCommand) webLogId =
|
||||||
cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString webLogId) |> ignore
|
cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString webLogId) |> ignore
|
||||||
|
|||||||
@@ -10,23 +10,23 @@ type SQLiteCategoryData (conn : SqliteConnection) =
|
|||||||
|
|
||||||
/// Add parameters for category INSERT or UPDATE statements
|
/// Add parameters for category INSERT or UPDATE statements
|
||||||
let addCategoryParameters (cmd : SqliteCommand) (cat : Category) =
|
let addCategoryParameters (cmd : SqliteCommand) (cat : Category) =
|
||||||
[ cmd.Parameters.AddWithValue ("@id", CategoryId.toString cat.Id)
|
[ cmd.Parameters.AddWithValue ("@id", CategoryId.toString cat.Id)
|
||||||
cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString cat.WebLogId)
|
cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString cat.WebLogId)
|
||||||
cmd.Parameters.AddWithValue ("@name", cat.Name)
|
cmd.Parameters.AddWithValue ("@name", cat.Name)
|
||||||
cmd.Parameters.AddWithValue ("@slug", cat.Slug)
|
cmd.Parameters.AddWithValue ("@slug", cat.Slug)
|
||||||
cmd.Parameters.AddWithValue ("@description", maybe cat.Description)
|
cmd.Parameters.AddWithValue ("@description", maybe cat.Description)
|
||||||
cmd.Parameters.AddWithValue ("@parentId", maybe (cat.ParentId |> Option.map CategoryId.toString))
|
cmd.Parameters.AddWithValue ("@parentId", maybe (cat.ParentId |> Option.map CategoryId.toString))
|
||||||
] |> ignore
|
] |> ignore
|
||||||
|
|
||||||
/// Add a category
|
/// Add a category
|
||||||
let add cat = backgroundTask {
|
let add cat = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <-
|
||||||
INSERT INTO category (
|
"INSERT INTO category (
|
||||||
id, web_log_id, name, slug, description, parent_id
|
id, web_log_id, name, slug, description, parent_id
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@id, @webLogId, @name, @slug, @description, @parentId
|
@id, @webLogId, @name, @slug, @description, @parentId
|
||||||
)"""
|
)"
|
||||||
addCategoryParameters cmd cat
|
addCategoryParameters cmd cat
|
||||||
let! _ = cmd.ExecuteNonQueryAsync ()
|
let! _ = cmd.ExecuteNonQueryAsync ()
|
||||||
()
|
()
|
||||||
@@ -68,24 +68,23 @@ type SQLiteCategoryData (conn : SqliteConnection) =
|
|||||||
ordered
|
ordered
|
||||||
|> Seq.map (fun it -> backgroundTask {
|
|> Seq.map (fun it -> backgroundTask {
|
||||||
// Parent category post counts include posts in subcategories
|
// Parent category post counts include posts in subcategories
|
||||||
|
let catSql, catParams =
|
||||||
|
ordered
|
||||||
|
|> Seq.filter (fun cat -> cat.ParentNames |> Array.contains it.Name)
|
||||||
|
|> Seq.map (fun cat -> cat.Id)
|
||||||
|
|> Seq.append (Seq.singleton it.Id)
|
||||||
|
|> List.ofSeq
|
||||||
|
|> inClause "AND pc.category_id" "catId" id
|
||||||
cmd.Parameters.Clear ()
|
cmd.Parameters.Clear ()
|
||||||
addWebLogId cmd webLogId
|
addWebLogId cmd webLogId
|
||||||
cmd.CommandText <- """
|
cmd.Parameters.AddRange catParams
|
||||||
|
cmd.CommandText <- $"
|
||||||
SELECT COUNT(DISTINCT p.id)
|
SELECT COUNT(DISTINCT p.id)
|
||||||
FROM post p
|
FROM post p
|
||||||
INNER JOIN post_category pc ON pc.post_id = p.id
|
INNER JOIN post_category pc ON pc.post_id = p.id
|
||||||
WHERE p.web_log_id = @webLogId
|
WHERE p.web_log_id = @webLogId
|
||||||
AND p.status = 'Published'
|
AND p.status = 'Published'
|
||||||
AND pc.category_id IN ("""
|
{catSql}"
|
||||||
ordered
|
|
||||||
|> Seq.filter (fun cat -> cat.ParentNames |> Array.contains it.Name)
|
|
||||||
|> Seq.map (fun cat -> cat.Id)
|
|
||||||
|> Seq.append (Seq.singleton it.Id)
|
|
||||||
|> Seq.iteri (fun idx item ->
|
|
||||||
if idx > 0 then cmd.CommandText <- $"{cmd.CommandText}, "
|
|
||||||
cmd.CommandText <- $"{cmd.CommandText}@catId{idx}"
|
|
||||||
cmd.Parameters.AddWithValue ($"@catId{idx}", item) |> ignore)
|
|
||||||
cmd.CommandText <- $"{cmd.CommandText})"
|
|
||||||
let! postCount = count cmd
|
let! postCount = count cmd
|
||||||
return it.Id, postCount
|
return it.Id, postCount
|
||||||
})
|
})
|
||||||
@@ -122,23 +121,29 @@ type SQLiteCategoryData (conn : SqliteConnection) =
|
|||||||
/// Delete a category
|
/// Delete a category
|
||||||
let delete catId webLogId = backgroundTask {
|
let delete catId webLogId = backgroundTask {
|
||||||
match! findById catId webLogId with
|
match! findById catId webLogId with
|
||||||
| Some _ ->
|
| Some cat ->
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
// Delete the category off all posts where it is assigned
|
// Reassign any children to the category's parent category
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <- "SELECT COUNT(id) FROM category WHERE parent_id = @parentId"
|
||||||
DELETE FROM post_category
|
cmd.Parameters.AddWithValue ("@parentId", CategoryId.toString catId) |> ignore
|
||||||
WHERE category_id = @id
|
let! children = count cmd
|
||||||
AND post_id IN (SELECT id FROM post WHERE web_log_id = @webLogId)"""
|
if children > 0 then
|
||||||
let catIdParameter = cmd.Parameters.AddWithValue ("@id", CategoryId.toString catId)
|
cmd.CommandText <- "UPDATE category SET parent_id = @newParentId WHERE parent_id = @parentId"
|
||||||
cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString webLogId) |> ignore
|
cmd.Parameters.AddWithValue ("@newParentId", maybe (cat.ParentId |> Option.map CategoryId.toString))
|
||||||
do! write cmd
|
|> ignore
|
||||||
// Delete the category itself
|
do! write cmd
|
||||||
cmd.CommandText <- "DELETE FROM category WHERE id = @id"
|
// Delete the category off all posts where it is assigned, and the category itself
|
||||||
|
cmd.CommandText <-
|
||||||
|
"DELETE FROM post_category
|
||||||
|
WHERE category_id = @id
|
||||||
|
AND post_id IN (SELECT id FROM post WHERE web_log_id = @webLogId);
|
||||||
|
DELETE FROM category WHERE id = @id"
|
||||||
cmd.Parameters.Clear ()
|
cmd.Parameters.Clear ()
|
||||||
cmd.Parameters.Add catIdParameter |> ignore
|
let _ = cmd.Parameters.AddWithValue ("@id", CategoryId.toString catId)
|
||||||
|
addWebLogId cmd webLogId
|
||||||
do! write cmd
|
do! write cmd
|
||||||
return true
|
return if children = 0 then CategoryDeleted else ReassignedChildCategories
|
||||||
| None -> return false
|
| None -> return CategoryNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore categories from a backup
|
/// Restore categories from a backup
|
||||||
@@ -150,14 +155,14 @@ type SQLiteCategoryData (conn : SqliteConnection) =
|
|||||||
/// Update a category
|
/// Update a category
|
||||||
let update cat = backgroundTask {
|
let update cat = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <-
|
||||||
UPDATE category
|
"UPDATE category
|
||||||
SET name = @name,
|
SET name = @name,
|
||||||
slug = @slug,
|
slug = @slug,
|
||||||
description = @description,
|
description = @description,
|
||||||
parent_id = @parentId
|
parent_id = @parentId
|
||||||
WHERE id = @id
|
WHERE id = @id
|
||||||
AND web_log_id = @webLogId"""
|
AND web_log_id = @webLogId"
|
||||||
addCategoryParameters cmd cat
|
addCategoryParameters cmd cat
|
||||||
do! write cmd
|
do! write cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,35 +4,29 @@ open System.Threading.Tasks
|
|||||||
open Microsoft.Data.Sqlite
|
open Microsoft.Data.Sqlite
|
||||||
open MyWebLog
|
open MyWebLog
|
||||||
open MyWebLog.Data
|
open MyWebLog.Data
|
||||||
|
open Newtonsoft.Json
|
||||||
|
|
||||||
/// SQLite myWebLog page data implementation
|
/// SQLite myWebLog page data implementation
|
||||||
type SQLitePageData (conn : SqliteConnection) =
|
type SQLitePageData (conn : SqliteConnection, ser : JsonSerializer) =
|
||||||
|
|
||||||
// SUPPORT FUNCTIONS
|
// SUPPORT FUNCTIONS
|
||||||
|
|
||||||
/// Add parameters for page INSERT or UPDATE statements
|
/// Add parameters for page INSERT or UPDATE statements
|
||||||
let addPageParameters (cmd : SqliteCommand) (page : Page) =
|
let addPageParameters (cmd : SqliteCommand) (page : Page) =
|
||||||
[ cmd.Parameters.AddWithValue ("@id", PageId.toString page.Id)
|
[ cmd.Parameters.AddWithValue ("@id", PageId.toString page.Id)
|
||||||
cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString page.WebLogId)
|
cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString page.WebLogId)
|
||||||
cmd.Parameters.AddWithValue ("@authorId", WebLogUserId.toString page.AuthorId)
|
cmd.Parameters.AddWithValue ("@authorId", WebLogUserId.toString page.AuthorId)
|
||||||
cmd.Parameters.AddWithValue ("@title", page.Title)
|
cmd.Parameters.AddWithValue ("@title", page.Title)
|
||||||
cmd.Parameters.AddWithValue ("@permalink", Permalink.toString page.Permalink)
|
cmd.Parameters.AddWithValue ("@permalink", Permalink.toString page.Permalink)
|
||||||
cmd.Parameters.AddWithValue ("@publishedOn", page.PublishedOn)
|
cmd.Parameters.AddWithValue ("@publishedOn", instantParam page.PublishedOn)
|
||||||
cmd.Parameters.AddWithValue ("@updatedOn", page.UpdatedOn)
|
cmd.Parameters.AddWithValue ("@updatedOn", instantParam page.UpdatedOn)
|
||||||
cmd.Parameters.AddWithValue ("@isInPageList", page.IsInPageList)
|
cmd.Parameters.AddWithValue ("@isInPageList", page.IsInPageList)
|
||||||
cmd.Parameters.AddWithValue ("@template", maybe page.Template)
|
cmd.Parameters.AddWithValue ("@template", maybe page.Template)
|
||||||
cmd.Parameters.AddWithValue ("@text", page.Text)
|
cmd.Parameters.AddWithValue ("@text", page.Text)
|
||||||
|
cmd.Parameters.AddWithValue ("@metaItems", maybe (if List.isEmpty page.Metadata then None
|
||||||
|
else Some (Utils.serialize ser page.Metadata)))
|
||||||
] |> ignore
|
] |> ignore
|
||||||
|
|
||||||
/// Append meta items to a page
|
|
||||||
let appendPageMeta (page : Page) = backgroundTask {
|
|
||||||
use cmd = conn.CreateCommand ()
|
|
||||||
cmd.CommandText <- "SELECT name, value FROM page_meta WHERE page_id = @id"
|
|
||||||
cmd.Parameters.AddWithValue ("@id", PageId.toString page.Id) |> ignore
|
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
|
||||||
return { page with Metadata = toList Map.toMetaItem rdr }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append revisions and permalinks to a page
|
/// Append revisions and permalinks to a page
|
||||||
let appendPageRevisionsAndPermalinks (page : Page) = backgroundTask {
|
let appendPageRevisionsAndPermalinks (page : Page) = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
@@ -48,47 +42,23 @@ type SQLitePageData (conn : SqliteConnection) =
|
|||||||
return { page with Revisions = toList Map.toRevision rdr }
|
return { page with Revisions = toList Map.toRevision rdr }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a page with no text (or meta items, prior permalinks, or revisions)
|
/// Shorthand for mapping a data reader to a page
|
||||||
let pageWithoutTextOrMeta rdr =
|
let toPage =
|
||||||
{ Map.toPage rdr with Text = "" }
|
Map.toPage ser
|
||||||
|
|
||||||
/// Update a page's metadata items
|
/// Return a page with no text (or prior permalinks or revisions)
|
||||||
let updatePageMeta pageId oldItems newItems = backgroundTask {
|
let pageWithoutText rdr =
|
||||||
let toDelete, toAdd = diffMetaItems oldItems newItems
|
{ toPage rdr with Text = "" }
|
||||||
if List.isEmpty toDelete && List.isEmpty toAdd then
|
|
||||||
return ()
|
|
||||||
else
|
|
||||||
use cmd = conn.CreateCommand ()
|
|
||||||
[ cmd.Parameters.AddWithValue ("@pageId", PageId.toString pageId)
|
|
||||||
cmd.Parameters.Add ("@name", SqliteType.Text)
|
|
||||||
cmd.Parameters.Add ("@value", SqliteType.Text)
|
|
||||||
] |> ignore
|
|
||||||
let runCmd (item : MetaItem) = backgroundTask {
|
|
||||||
cmd.Parameters["@name" ].Value <- item.Name
|
|
||||||
cmd.Parameters["@value"].Value <- item.Value
|
|
||||||
do! write cmd
|
|
||||||
}
|
|
||||||
cmd.CommandText <- "DELETE FROM page_meta WHERE page_id = @pageId AND name = @name AND value = @value"
|
|
||||||
toDelete
|
|
||||||
|> List.map runCmd
|
|
||||||
|> Task.WhenAll
|
|
||||||
|> ignore
|
|
||||||
cmd.CommandText <- "INSERT INTO page_meta VALUES (@pageId, @name, @value)"
|
|
||||||
toAdd
|
|
||||||
|> List.map runCmd
|
|
||||||
|> Task.WhenAll
|
|
||||||
|> ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update a page's prior permalinks
|
/// Update a page's prior permalinks
|
||||||
let updatePagePermalinks pageId oldLinks newLinks = backgroundTask {
|
let updatePagePermalinks pageId oldLinks newLinks = backgroundTask {
|
||||||
let toDelete, toAdd = diffPermalinks oldLinks newLinks
|
let toDelete, toAdd = Utils.diffPermalinks oldLinks newLinks
|
||||||
if List.isEmpty toDelete && List.isEmpty toAdd then
|
if List.isEmpty toDelete && List.isEmpty toAdd then
|
||||||
return ()
|
return ()
|
||||||
else
|
else
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
[ cmd.Parameters.AddWithValue ("@pageId", PageId.toString pageId)
|
[ cmd.Parameters.AddWithValue ("@pageId", PageId.toString pageId)
|
||||||
cmd.Parameters.Add ("@link", SqliteType.Text)
|
cmd.Parameters.Add ("@link", SqliteType.Text)
|
||||||
] |> ignore
|
] |> ignore
|
||||||
let runCmd link = backgroundTask {
|
let runCmd link = backgroundTask {
|
||||||
cmd.Parameters["@link"].Value <- Permalink.toString link
|
cmd.Parameters["@link"].Value <- Permalink.toString link
|
||||||
@@ -108,15 +78,15 @@ type SQLitePageData (conn : SqliteConnection) =
|
|||||||
|
|
||||||
/// Update a page's revisions
|
/// Update a page's revisions
|
||||||
let updatePageRevisions pageId oldRevs newRevs = backgroundTask {
|
let updatePageRevisions pageId oldRevs newRevs = backgroundTask {
|
||||||
let toDelete, toAdd = diffRevisions oldRevs newRevs
|
let toDelete, toAdd = Utils.diffRevisions oldRevs newRevs
|
||||||
if List.isEmpty toDelete && List.isEmpty toAdd then
|
if List.isEmpty toDelete && List.isEmpty toAdd then
|
||||||
return ()
|
return ()
|
||||||
else
|
else
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
let runCmd withText rev = backgroundTask {
|
let runCmd withText rev = backgroundTask {
|
||||||
cmd.Parameters.Clear ()
|
cmd.Parameters.Clear ()
|
||||||
[ cmd.Parameters.AddWithValue ("@pageId", PageId.toString pageId)
|
[ cmd.Parameters.AddWithValue ("@pageId", PageId.toString pageId)
|
||||||
cmd.Parameters.AddWithValue ("@asOf", rev.AsOf)
|
cmd.Parameters.AddWithValue ("@asOf", instantParam rev.AsOf)
|
||||||
] |> ignore
|
] |> ignore
|
||||||
if withText then cmd.Parameters.AddWithValue ("@text", MarkupText.toString rev.Text) |> ignore
|
if withText then cmd.Parameters.AddWithValue ("@text", MarkupText.toString rev.Text) |> ignore
|
||||||
do! write cmd
|
do! write cmd
|
||||||
@@ -139,17 +109,16 @@ type SQLitePageData (conn : SqliteConnection) =
|
|||||||
let add page = backgroundTask {
|
let add page = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
// The page itself
|
// The page itself
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <-
|
||||||
INSERT INTO page (
|
"INSERT INTO page (
|
||||||
id, web_log_id, author_id, title, permalink, published_on, updated_on, is_in_page_list, template,
|
id, web_log_id, author_id, title, permalink, published_on, updated_on, is_in_page_list, template,
|
||||||
page_text
|
page_text, meta_items
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@id, @webLogId, @authorId, @title, @permalink, @publishedOn, @updatedOn, @isInPageList, @template,
|
@id, @webLogId, @authorId, @title, @permalink, @publishedOn, @updatedOn, @isInPageList, @template,
|
||||||
@text
|
@text, @metaItems
|
||||||
)"""
|
)"
|
||||||
addPageParameters cmd page
|
addPageParameters cmd page
|
||||||
do! write cmd
|
do! write cmd
|
||||||
do! updatePageMeta page.Id [] page.Metadata
|
|
||||||
do! updatePagePermalinks page.Id [] page.PriorPermalinks
|
do! updatePagePermalinks page.Id [] page.PriorPermalinks
|
||||||
do! updatePageRevisions page.Id [] page.Revisions
|
do! updatePageRevisions page.Id [] page.Revisions
|
||||||
}
|
}
|
||||||
@@ -160,7 +129,7 @@ type SQLitePageData (conn : SqliteConnection) =
|
|||||||
cmd.CommandText <- "SELECT * FROM page WHERE web_log_id = @webLogId ORDER BY LOWER(title)"
|
cmd.CommandText <- "SELECT * FROM page WHERE web_log_id = @webLogId ORDER BY LOWER(title)"
|
||||||
addWebLogId cmd webLogId
|
addWebLogId cmd webLogId
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
return toList pageWithoutTextOrMeta rdr
|
return toList pageWithoutText rdr
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Count all pages for the given web log
|
/// Count all pages for the given web log
|
||||||
@@ -174,11 +143,11 @@ type SQLitePageData (conn : SqliteConnection) =
|
|||||||
/// Count all pages shown in the page list for the given web log
|
/// Count all pages shown in the page list for the given web log
|
||||||
let countListed webLogId = backgroundTask {
|
let countListed webLogId = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <-
|
||||||
SELECT COUNT(id)
|
"SELECT COUNT(id)
|
||||||
FROM page
|
FROM page
|
||||||
WHERE web_log_id = @webLogId
|
WHERE web_log_id = @webLogId
|
||||||
AND is_in_page_list = @isInPageList"""
|
AND is_in_page_list = @isInPageList"
|
||||||
addWebLogId cmd webLogId
|
addWebLogId cmd webLogId
|
||||||
cmd.Parameters.AddWithValue ("@isInPageList", true) |> ignore
|
cmd.Parameters.AddWithValue ("@isInPageList", true) |> ignore
|
||||||
return! count cmd
|
return! count cmd
|
||||||
@@ -190,11 +159,7 @@ type SQLitePageData (conn : SqliteConnection) =
|
|||||||
cmd.CommandText <- "SELECT * FROM page WHERE id = @id"
|
cmd.CommandText <- "SELECT * FROM page WHERE id = @id"
|
||||||
cmd.Parameters.AddWithValue ("@id", PageId.toString pageId) |> ignore
|
cmd.Parameters.AddWithValue ("@id", PageId.toString pageId) |> ignore
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
match Helpers.verifyWebLog<Page> webLogId (fun it -> it.WebLogId) Map.toPage rdr with
|
return Helpers.verifyWebLog<Page> webLogId (fun it -> it.WebLogId) (Map.toPage ser) rdr
|
||||||
| Some page ->
|
|
||||||
let! page = appendPageMeta page
|
|
||||||
return Some page
|
|
||||||
| None -> return None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find a complete page by its ID
|
/// Find a complete page by its ID
|
||||||
@@ -211,11 +176,10 @@ type SQLitePageData (conn : SqliteConnection) =
|
|||||||
| Some _ ->
|
| Some _ ->
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.Parameters.AddWithValue ("@id", PageId.toString pageId) |> ignore
|
cmd.Parameters.AddWithValue ("@id", PageId.toString pageId) |> ignore
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <-
|
||||||
DELETE FROM page_revision WHERE page_id = @id;
|
"DELETE FROM page_revision WHERE page_id = @id;
|
||||||
DELETE FROM page_permalink WHERE page_id = @id;
|
DELETE FROM page_permalink WHERE page_id = @id;
|
||||||
DELETE FROM page_meta WHERE page_id = @id;
|
DELETE FROM page WHERE id = @id"
|
||||||
DELETE FROM page WHERE id = @id"""
|
|
||||||
do! write cmd
|
do! write cmd
|
||||||
return true
|
return true
|
||||||
| None -> return false
|
| None -> return false
|
||||||
@@ -228,29 +192,21 @@ type SQLitePageData (conn : SqliteConnection) =
|
|||||||
addWebLogId cmd webLogId
|
addWebLogId cmd webLogId
|
||||||
cmd.Parameters.AddWithValue ("@link", Permalink.toString permalink) |> ignore
|
cmd.Parameters.AddWithValue ("@link", Permalink.toString permalink) |> ignore
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
if rdr.Read () then
|
return if rdr.Read () then Some (toPage rdr) else None
|
||||||
let! page = appendPageMeta (Map.toPage rdr)
|
|
||||||
return Some page
|
|
||||||
else
|
|
||||||
return None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find the current permalink within a set of potential prior permalinks for the given web log
|
/// Find the current permalink within a set of potential prior permalinks for the given web log
|
||||||
let findCurrentPermalink permalinks webLogId = backgroundTask {
|
let findCurrentPermalink permalinks webLogId = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- """
|
let linkSql, linkParams = inClause "AND pp.permalink" "link" Permalink.toString permalinks
|
||||||
|
cmd.CommandText <- $"
|
||||||
SELECT p.permalink
|
SELECT p.permalink
|
||||||
FROM page p
|
FROM page p
|
||||||
INNER JOIN page_permalink pp ON pp.page_id = p.id
|
INNER JOIN page_permalink pp ON pp.page_id = p.id
|
||||||
WHERE p.web_log_id = @webLogId
|
WHERE p.web_log_id = @webLogId
|
||||||
AND pp.permalink IN ("""
|
{linkSql}"
|
||||||
permalinks
|
|
||||||
|> List.iteri (fun idx link ->
|
|
||||||
if idx > 0 then cmd.CommandText <- $"{cmd.CommandText}, "
|
|
||||||
cmd.CommandText <- $"{cmd.CommandText}@link{idx}"
|
|
||||||
cmd.Parameters.AddWithValue ($"@link{idx}", Permalink.toString link) |> ignore)
|
|
||||||
cmd.CommandText <- $"{cmd.CommandText})"
|
|
||||||
addWebLogId cmd webLogId
|
addWebLogId cmd webLogId
|
||||||
|
cmd.Parameters.AddRange linkParams
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
return if rdr.Read () then Some (Map.toPermalink rdr) else None
|
return if rdr.Read () then Some (Map.toPermalink rdr) else None
|
||||||
}
|
}
|
||||||
@@ -262,11 +218,8 @@ type SQLitePageData (conn : SqliteConnection) =
|
|||||||
addWebLogId cmd webLogId
|
addWebLogId cmd webLogId
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
let! pages =
|
let! pages =
|
||||||
toList Map.toPage rdr
|
toList toPage rdr
|
||||||
|> List.map (fun page -> backgroundTask {
|
|> List.map (fun page -> backgroundTask { return! appendPageRevisionsAndPermalinks page })
|
||||||
let! page = appendPageMeta page
|
|
||||||
return! appendPageRevisionsAndPermalinks page
|
|
||||||
})
|
|
||||||
|> Task.WhenAll
|
|> Task.WhenAll
|
||||||
return List.ofArray pages
|
return List.ofArray pages
|
||||||
}
|
}
|
||||||
@@ -274,37 +227,33 @@ type SQLitePageData (conn : SqliteConnection) =
|
|||||||
/// Get all listed pages for the given web log (without revisions, prior permalinks, or text)
|
/// Get all listed pages for the given web log (without revisions, prior permalinks, or text)
|
||||||
let findListed webLogId = backgroundTask {
|
let findListed webLogId = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <-
|
||||||
SELECT *
|
"SELECT *
|
||||||
FROM page
|
FROM page
|
||||||
WHERE web_log_id = @webLogId
|
WHERE web_log_id = @webLogId
|
||||||
AND is_in_page_list = @isInPageList
|
AND is_in_page_list = @isInPageList
|
||||||
ORDER BY LOWER(title)"""
|
ORDER BY LOWER(title)"
|
||||||
addWebLogId cmd webLogId
|
addWebLogId cmd webLogId
|
||||||
cmd.Parameters.AddWithValue ("@isInPageList", true) |> ignore
|
cmd.Parameters.AddWithValue ("@isInPageList", true) |> ignore
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
let! pages =
|
return toList pageWithoutText rdr
|
||||||
toList pageWithoutTextOrMeta rdr
|
|
||||||
|> List.map (fun page -> backgroundTask { return! appendPageMeta page })
|
|
||||||
|> Task.WhenAll
|
|
||||||
return List.ofArray pages
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a page of pages for the given web log (without revisions, prior permalinks, or metadata)
|
/// Get a page of pages for the given web log (without revisions, prior permalinks, or metadata)
|
||||||
let findPageOfPages webLogId pageNbr = backgroundTask {
|
let findPageOfPages webLogId pageNbr = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <-
|
||||||
SELECT *
|
"SELECT *
|
||||||
FROM page
|
FROM page
|
||||||
WHERE web_log_id = @webLogId
|
WHERE web_log_id = @webLogId
|
||||||
ORDER BY LOWER(title)
|
ORDER BY LOWER(title)
|
||||||
LIMIT @pageSize OFFSET @toSkip"""
|
LIMIT @pageSize OFFSET @toSkip"
|
||||||
addWebLogId cmd webLogId
|
addWebLogId cmd webLogId
|
||||||
[ cmd.Parameters.AddWithValue ("@pageSize", 26)
|
[ cmd.Parameters.AddWithValue ("@pageSize", 26)
|
||||||
cmd.Parameters.AddWithValue ("@toSkip", (pageNbr - 1) * 25)
|
cmd.Parameters.AddWithValue ("@toSkip", (pageNbr - 1) * 25)
|
||||||
] |> ignore
|
] |> ignore
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
return toList Map.toPage rdr
|
return toList toPage rdr
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore pages from a backup
|
/// Restore pages from a backup
|
||||||
@@ -318,21 +267,21 @@ type SQLitePageData (conn : SqliteConnection) =
|
|||||||
match! findFullById page.Id page.WebLogId with
|
match! findFullById page.Id page.WebLogId with
|
||||||
| Some oldPage ->
|
| Some oldPage ->
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <-
|
||||||
UPDATE page
|
"UPDATE page
|
||||||
SET author_id = @authorId,
|
SET author_id = @authorId,
|
||||||
title = @title,
|
title = @title,
|
||||||
permalink = @permalink,
|
permalink = @permalink,
|
||||||
published_on = @publishedOn,
|
published_on = @publishedOn,
|
||||||
updated_on = @updatedOn,
|
updated_on = @updatedOn,
|
||||||
is_in_page_list = @isInPageList,
|
is_in_page_list = @isInPageList,
|
||||||
template = @template,
|
template = @template,
|
||||||
page_text = @text
|
page_text = @text,
|
||||||
WHERE id = @pageId
|
meta_items = @metaItems
|
||||||
AND web_log_id = @webLogId"""
|
WHERE id = @id
|
||||||
|
AND web_log_id = @webLogId"
|
||||||
addPageParameters cmd page
|
addPageParameters cmd page
|
||||||
do! write cmd
|
do! write cmd
|
||||||
do! updatePageMeta page.Id oldPage.Metadata page.Metadata
|
|
||||||
do! updatePagePermalinks page.Id oldPage.PriorPermalinks page.PriorPermalinks
|
do! updatePagePermalinks page.Id oldPage.PriorPermalinks page.PriorPermalinks
|
||||||
do! updatePageRevisions page.Id oldPage.Revisions page.Revisions
|
do! updatePageRevisions page.Id oldPage.Revisions page.Revisions
|
||||||
return ()
|
return ()
|
||||||
|
|||||||
@@ -1,53 +1,38 @@
|
|||||||
namespace MyWebLog.Data.SQLite
|
namespace MyWebLog.Data.SQLite
|
||||||
|
|
||||||
open System
|
|
||||||
open System.Threading.Tasks
|
open System.Threading.Tasks
|
||||||
open Microsoft.Data.Sqlite
|
open Microsoft.Data.Sqlite
|
||||||
open MyWebLog
|
open MyWebLog
|
||||||
open MyWebLog.Data
|
open MyWebLog.Data
|
||||||
|
open Newtonsoft.Json
|
||||||
|
open NodaTime
|
||||||
|
|
||||||
/// SQLite myWebLog post data implementation
|
/// SQLite myWebLog post data implementation
|
||||||
type SQLitePostData (conn : SqliteConnection) =
|
type SQLitePostData (conn : SqliteConnection, ser : JsonSerializer) =
|
||||||
|
|
||||||
// SUPPORT FUNCTIONS
|
// SUPPORT FUNCTIONS
|
||||||
|
|
||||||
/// Add parameters for post INSERT or UPDATE statements
|
/// Add parameters for post INSERT or UPDATE statements
|
||||||
let addPostParameters (cmd : SqliteCommand) (post : Post) =
|
let addPostParameters (cmd : SqliteCommand) (post : Post) =
|
||||||
[ cmd.Parameters.AddWithValue ("@id", PostId.toString post.Id)
|
[ cmd.Parameters.AddWithValue ("@id", PostId.toString post.Id)
|
||||||
cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString post.WebLogId)
|
cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString post.WebLogId)
|
||||||
cmd.Parameters.AddWithValue ("@authorId", WebLogUserId.toString post.AuthorId)
|
cmd.Parameters.AddWithValue ("@authorId", WebLogUserId.toString post.AuthorId)
|
||||||
cmd.Parameters.AddWithValue ("@status", PostStatus.toString post.Status)
|
cmd.Parameters.AddWithValue ("@status", PostStatus.toString post.Status)
|
||||||
cmd.Parameters.AddWithValue ("@title", post.Title)
|
cmd.Parameters.AddWithValue ("@title", post.Title)
|
||||||
cmd.Parameters.AddWithValue ("@permalink", Permalink.toString post.Permalink)
|
cmd.Parameters.AddWithValue ("@permalink", Permalink.toString post.Permalink)
|
||||||
cmd.Parameters.AddWithValue ("@publishedOn", maybe post.PublishedOn)
|
cmd.Parameters.AddWithValue ("@publishedOn", maybeInstant post.PublishedOn)
|
||||||
cmd.Parameters.AddWithValue ("@updatedOn", post.UpdatedOn)
|
cmd.Parameters.AddWithValue ("@updatedOn", instantParam post.UpdatedOn)
|
||||||
cmd.Parameters.AddWithValue ("@template", maybe post.Template)
|
cmd.Parameters.AddWithValue ("@template", maybe post.Template)
|
||||||
cmd.Parameters.AddWithValue ("@text", post.Text)
|
cmd.Parameters.AddWithValue ("@text", post.Text)
|
||||||
|
cmd.Parameters.AddWithValue ("@episode", maybe (if Option.isSome post.Episode then
|
||||||
|
Some (Utils.serialize ser post.Episode)
|
||||||
|
else None))
|
||||||
|
cmd.Parameters.AddWithValue ("@metaItems", maybe (if List.isEmpty post.Metadata then None
|
||||||
|
else Some (Utils.serialize ser post.Metadata)))
|
||||||
] |> ignore
|
] |> ignore
|
||||||
|
|
||||||
/// Add parameters for episode INSERT or UPDATE statements
|
/// Append category IDs and tags to a post
|
||||||
let addEpisodeParameters (cmd : SqliteCommand) (ep : Episode) =
|
let appendPostCategoryAndTag (post : Post) = backgroundTask {
|
||||||
[ cmd.Parameters.AddWithValue ("@media", ep.Media)
|
|
||||||
cmd.Parameters.AddWithValue ("@length", ep.Length)
|
|
||||||
cmd.Parameters.AddWithValue ("@duration", maybe ep.Duration)
|
|
||||||
cmd.Parameters.AddWithValue ("@mediaType", maybe ep.MediaType)
|
|
||||||
cmd.Parameters.AddWithValue ("@imageUrl", maybe ep.ImageUrl)
|
|
||||||
cmd.Parameters.AddWithValue ("@subtitle", maybe ep.Subtitle)
|
|
||||||
cmd.Parameters.AddWithValue ("@explicit", maybe (ep.Explicit |> Option.map ExplicitRating.toString))
|
|
||||||
cmd.Parameters.AddWithValue ("@chapterFile", maybe ep.ChapterFile)
|
|
||||||
cmd.Parameters.AddWithValue ("@chapterType", maybe ep.ChapterType)
|
|
||||||
cmd.Parameters.AddWithValue ("@transcriptUrl", maybe ep.TranscriptUrl)
|
|
||||||
cmd.Parameters.AddWithValue ("@transcriptType", maybe ep.TranscriptType)
|
|
||||||
cmd.Parameters.AddWithValue ("@transcriptLang", maybe ep.TranscriptLang)
|
|
||||||
cmd.Parameters.AddWithValue ("@transcriptCaptions", maybe ep.TranscriptCaptions)
|
|
||||||
cmd.Parameters.AddWithValue ("@seasonNumber", maybe ep.SeasonNumber)
|
|
||||||
cmd.Parameters.AddWithValue ("@seasonDescription", maybe ep.SeasonDescription)
|
|
||||||
cmd.Parameters.AddWithValue ("@episodeNumber", maybe (ep.EpisodeNumber |> Option.map string))
|
|
||||||
cmd.Parameters.AddWithValue ("@episodeDescription", maybe ep.EpisodeDescription)
|
|
||||||
] |> ignore
|
|
||||||
|
|
||||||
/// Append category IDs, tags, and meta items to a post
|
|
||||||
let appendPostCategoryTagAndMeta (post : Post) = backgroundTask {
|
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.Parameters.AddWithValue ("@id", PostId.toString post.Id) |> ignore
|
cmd.Parameters.AddWithValue ("@id", PostId.toString post.Id) |> ignore
|
||||||
|
|
||||||
@@ -58,12 +43,7 @@ type SQLitePostData (conn : SqliteConnection) =
|
|||||||
|
|
||||||
cmd.CommandText <- "SELECT tag FROM post_tag WHERE post_id = @id"
|
cmd.CommandText <- "SELECT tag FROM post_tag WHERE post_id = @id"
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
let post = { post with Tags = toList (Map.getString "tag") rdr }
|
return { post with Tags = toList (Map.getString "tag") rdr }
|
||||||
do! rdr.CloseAsync ()
|
|
||||||
|
|
||||||
cmd.CommandText <- "SELECT name, value FROM post_meta WHERE post_id = @id"
|
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
|
||||||
return { post with Metadata = toList Map.toMetaItem rdr }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append revisions and permalinks to a post
|
/// Append revisions and permalinks to a post
|
||||||
@@ -82,7 +62,11 @@ type SQLitePostData (conn : SqliteConnection) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The SELECT statement for a post that will include episode data, if it exists
|
/// The SELECT statement for a post that will include episode data, if it exists
|
||||||
let selectPost = "SELECT p.*, e.* FROM post p LEFT JOIN post_episode e ON e.post_id = p.id"
|
let selectPost = "SELECT p.* FROM post p"
|
||||||
|
|
||||||
|
/// Shorthand for mapping a data reader to a post
|
||||||
|
let toPost =
|
||||||
|
Map.toPost ser
|
||||||
|
|
||||||
/// Find just-the-post by its ID for the given web log (excludes category, tag, meta, revisions, and permalinks)
|
/// Find just-the-post by its ID for the given web log (excludes category, tag, meta, revisions, and permalinks)
|
||||||
let findPostById postId webLogId = backgroundTask {
|
let findPostById postId webLogId = backgroundTask {
|
||||||
@@ -90,22 +74,22 @@ type SQLitePostData (conn : SqliteConnection) =
|
|||||||
cmd.CommandText <- $"{selectPost} WHERE p.id = @id"
|
cmd.CommandText <- $"{selectPost} WHERE p.id = @id"
|
||||||
cmd.Parameters.AddWithValue ("@id", PostId.toString postId) |> ignore
|
cmd.Parameters.AddWithValue ("@id", PostId.toString postId) |> ignore
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
return Helpers.verifyWebLog<Post> webLogId (fun p -> p.WebLogId) Map.toPost rdr
|
return Helpers.verifyWebLog<Post> webLogId (fun p -> p.WebLogId) toPost rdr
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a post with no revisions, prior permalinks, or text
|
/// Return a post with no revisions, prior permalinks, or text
|
||||||
let postWithoutText rdr =
|
let postWithoutText rdr =
|
||||||
{ Map.toPost rdr with Text = "" }
|
{ toPost rdr with Text = "" }
|
||||||
|
|
||||||
/// Update a post's assigned categories
|
/// Update a post's assigned categories
|
||||||
let updatePostCategories postId oldCats newCats = backgroundTask {
|
let updatePostCategories postId oldCats newCats = backgroundTask {
|
||||||
let toDelete, toAdd = diffLists oldCats newCats CategoryId.toString
|
let toDelete, toAdd = Utils.diffLists oldCats newCats CategoryId.toString
|
||||||
if List.isEmpty toDelete && List.isEmpty toAdd then
|
if List.isEmpty toDelete && List.isEmpty toAdd then
|
||||||
return ()
|
return ()
|
||||||
else
|
else
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
[ cmd.Parameters.AddWithValue ("@postId", PostId.toString postId)
|
[ cmd.Parameters.AddWithValue ("@postId", PostId.toString postId)
|
||||||
cmd.Parameters.Add ("@categoryId", SqliteType.Text)
|
cmd.Parameters.Add ("@categoryId", SqliteType.Text)
|
||||||
] |> ignore
|
] |> ignore
|
||||||
let runCmd catId = backgroundTask {
|
let runCmd catId = backgroundTask {
|
||||||
cmd.Parameters["@categoryId"].Value <- CategoryId.toString catId
|
cmd.Parameters["@categoryId"].Value <- CategoryId.toString catId
|
||||||
@@ -125,13 +109,13 @@ type SQLitePostData (conn : SqliteConnection) =
|
|||||||
|
|
||||||
/// Update a post's assigned categories
|
/// Update a post's assigned categories
|
||||||
let updatePostTags postId (oldTags : string list) newTags = backgroundTask {
|
let updatePostTags postId (oldTags : string list) newTags = backgroundTask {
|
||||||
let toDelete, toAdd = diffLists oldTags newTags id
|
let toDelete, toAdd = Utils.diffLists oldTags newTags id
|
||||||
if List.isEmpty toDelete && List.isEmpty toAdd then
|
if List.isEmpty toDelete && List.isEmpty toAdd then
|
||||||
return ()
|
return ()
|
||||||
else
|
else
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
[ cmd.Parameters.AddWithValue ("@postId", PostId.toString postId)
|
[ cmd.Parameters.AddWithValue ("@postId", PostId.toString postId)
|
||||||
cmd.Parameters.Add ("@tag", SqliteType.Text)
|
cmd.Parameters.Add ("@tag", SqliteType.Text)
|
||||||
] |> ignore
|
] |> ignore
|
||||||
let runCmd (tag : string) = backgroundTask {
|
let runCmd (tag : string) = backgroundTask {
|
||||||
cmd.Parameters["@tag"].Value <- tag
|
cmd.Parameters["@tag"].Value <- tag
|
||||||
@@ -149,95 +133,15 @@ type SQLitePostData (conn : SqliteConnection) =
|
|||||||
|> ignore
|
|> ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update an episode
|
|
||||||
let updatePostEpisode (post : Post) = backgroundTask {
|
|
||||||
use cmd = conn.CreateCommand ()
|
|
||||||
cmd.CommandText <- "SELECT COUNT(post_id) FROM post_episode WHERE post_id = @postId"
|
|
||||||
cmd.Parameters.AddWithValue ("@postId", PostId.toString post.Id) |> ignore
|
|
||||||
let! count = count cmd
|
|
||||||
if count = 1 then
|
|
||||||
match post.Episode with
|
|
||||||
| Some ep ->
|
|
||||||
cmd.CommandText <- """
|
|
||||||
UPDATE post_episode
|
|
||||||
SET media = @media,
|
|
||||||
length = @length,
|
|
||||||
duration = @duration,
|
|
||||||
media_type = @mediaType,
|
|
||||||
image_url = @imageUrl,
|
|
||||||
subtitle = @subtitle,
|
|
||||||
explicit = @explicit,
|
|
||||||
chapter_file = @chapterFile,
|
|
||||||
chapter_type = @chapterType,
|
|
||||||
transcript_url = @transcriptUrl,
|
|
||||||
transcript_type = @transcriptType,
|
|
||||||
transcript_lang = @transcriptLang,
|
|
||||||
transcript_captions = @transcriptCaptions,
|
|
||||||
season_number = @seasonNumber,
|
|
||||||
season_description = @seasonDescription,
|
|
||||||
episode_number = @episodeNumber,
|
|
||||||
episode_description = @episodeDescription
|
|
||||||
WHERE post_id = @postId"""
|
|
||||||
addEpisodeParameters cmd ep
|
|
||||||
do! write cmd
|
|
||||||
| None ->
|
|
||||||
cmd.CommandText <- "DELETE FROM post_episode WHERE post_id = @postId"
|
|
||||||
do! write cmd
|
|
||||||
else
|
|
||||||
match post.Episode with
|
|
||||||
| Some ep ->
|
|
||||||
cmd.CommandText <- """
|
|
||||||
INSERT INTO post_episode (
|
|
||||||
post_id, media, length, duration, media_type, image_url, subtitle, explicit, chapter_file,
|
|
||||||
chapter_type, transcript_url, transcript_type, transcript_lang, transcript_captions,
|
|
||||||
season_number, season_description, episode_number, episode_description
|
|
||||||
) VALUES (
|
|
||||||
@postId, @media, @length, @duration, @mediaType, @imageUrl, @subtitle, @explicit, @chapterFile,
|
|
||||||
@chapterType, @transcriptUrl, @transcriptType, @transcriptLang, @transcriptCaptions,
|
|
||||||
@seasonNumber, @seasonDescription, @episodeNumber, @episodeDescription
|
|
||||||
)"""
|
|
||||||
addEpisodeParameters cmd ep
|
|
||||||
do! write cmd
|
|
||||||
| None -> ()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update a post's metadata items
|
|
||||||
let updatePostMeta postId oldItems newItems = backgroundTask {
|
|
||||||
let toDelete, toAdd = diffMetaItems oldItems newItems
|
|
||||||
if List.isEmpty toDelete && List.isEmpty toAdd then
|
|
||||||
return ()
|
|
||||||
else
|
|
||||||
use cmd = conn.CreateCommand ()
|
|
||||||
[ cmd.Parameters.AddWithValue ("@postId", PostId.toString postId)
|
|
||||||
cmd.Parameters.Add ("@name", SqliteType.Text)
|
|
||||||
cmd.Parameters.Add ("@value", SqliteType.Text)
|
|
||||||
] |> ignore
|
|
||||||
let runCmd (item : MetaItem) = backgroundTask {
|
|
||||||
cmd.Parameters["@name" ].Value <- item.Name
|
|
||||||
cmd.Parameters["@value"].Value <- item.Value
|
|
||||||
do! write cmd
|
|
||||||
}
|
|
||||||
cmd.CommandText <- "DELETE FROM post_meta WHERE post_id = @postId AND name = @name AND value = @value"
|
|
||||||
toDelete
|
|
||||||
|> List.map runCmd
|
|
||||||
|> Task.WhenAll
|
|
||||||
|> ignore
|
|
||||||
cmd.CommandText <- "INSERT INTO post_meta VALUES (@postId, @name, @value)"
|
|
||||||
toAdd
|
|
||||||
|> List.map runCmd
|
|
||||||
|> Task.WhenAll
|
|
||||||
|> ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update a post's prior permalinks
|
/// Update a post's prior permalinks
|
||||||
let updatePostPermalinks postId oldLinks newLinks = backgroundTask {
|
let updatePostPermalinks postId oldLinks newLinks = backgroundTask {
|
||||||
let toDelete, toAdd = diffPermalinks oldLinks newLinks
|
let toDelete, toAdd = Utils.diffPermalinks oldLinks newLinks
|
||||||
if List.isEmpty toDelete && List.isEmpty toAdd then
|
if List.isEmpty toDelete && List.isEmpty toAdd then
|
||||||
return ()
|
return ()
|
||||||
else
|
else
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
[ cmd.Parameters.AddWithValue ("@postId", PostId.toString postId)
|
[ cmd.Parameters.AddWithValue ("@postId", PostId.toString postId)
|
||||||
cmd.Parameters.Add ("@link", SqliteType.Text)
|
cmd.Parameters.Add ("@link", SqliteType.Text)
|
||||||
] |> ignore
|
] |> ignore
|
||||||
let runCmd link = backgroundTask {
|
let runCmd link = backgroundTask {
|
||||||
cmd.Parameters["@link"].Value <- Permalink.toString link
|
cmd.Parameters["@link"].Value <- Permalink.toString link
|
||||||
@@ -257,15 +161,15 @@ type SQLitePostData (conn : SqliteConnection) =
|
|||||||
|
|
||||||
/// Update a post's revisions
|
/// Update a post's revisions
|
||||||
let updatePostRevisions postId oldRevs newRevs = backgroundTask {
|
let updatePostRevisions postId oldRevs newRevs = backgroundTask {
|
||||||
let toDelete, toAdd = diffRevisions oldRevs newRevs
|
let toDelete, toAdd = Utils.diffRevisions oldRevs newRevs
|
||||||
if List.isEmpty toDelete && List.isEmpty toAdd then
|
if List.isEmpty toDelete && List.isEmpty toAdd then
|
||||||
return ()
|
return ()
|
||||||
else
|
else
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
let runCmd withText rev = backgroundTask {
|
let runCmd withText rev = backgroundTask {
|
||||||
cmd.Parameters.Clear ()
|
cmd.Parameters.Clear ()
|
||||||
[ cmd.Parameters.AddWithValue ("@postId", PostId.toString postId)
|
[ cmd.Parameters.AddWithValue ("@postId", PostId.toString postId)
|
||||||
cmd.Parameters.AddWithValue ("@asOf", rev.AsOf)
|
cmd.Parameters.AddWithValue ("@asOf", instantParam rev.AsOf)
|
||||||
] |> ignore
|
] |> ignore
|
||||||
if withText then cmd.Parameters.AddWithValue ("@text", MarkupText.toString rev.Text) |> ignore
|
if withText then cmd.Parameters.AddWithValue ("@text", MarkupText.toString rev.Text) |> ignore
|
||||||
do! write cmd
|
do! write cmd
|
||||||
@@ -287,18 +191,18 @@ type SQLitePostData (conn : SqliteConnection) =
|
|||||||
/// Add a post
|
/// Add a post
|
||||||
let add post = backgroundTask {
|
let add post = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <-
|
||||||
INSERT INTO post (
|
"INSERT INTO post (
|
||||||
id, web_log_id, author_id, status, title, permalink, published_on, updated_on, template, post_text
|
id, web_log_id, author_id, status, title, permalink, published_on, updated_on, template, post_text,
|
||||||
|
episode, meta_items
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@id, @webLogId, @authorId, @status, @title, @permalink, @publishedOn, @updatedOn, @template, @text
|
@id, @webLogId, @authorId, @status, @title, @permalink, @publishedOn, @updatedOn, @template, @text,
|
||||||
)"""
|
@episode, @metaItems
|
||||||
|
)"
|
||||||
addPostParameters cmd post
|
addPostParameters cmd post
|
||||||
do! write cmd
|
do! write cmd
|
||||||
do! updatePostCategories post.Id [] post.CategoryIds
|
do! updatePostCategories post.Id [] post.CategoryIds
|
||||||
do! updatePostTags post.Id [] post.Tags
|
do! updatePostTags post.Id [] post.Tags
|
||||||
do! updatePostEpisode post
|
|
||||||
do! updatePostMeta post.Id [] post.Metadata
|
|
||||||
do! updatePostPermalinks post.Id [] post.PriorPermalinks
|
do! updatePostPermalinks post.Id [] post.PriorPermalinks
|
||||||
do! updatePostRevisions post.Id [] post.Revisions
|
do! updatePostRevisions post.Id [] post.Revisions
|
||||||
}
|
}
|
||||||
@@ -316,7 +220,7 @@ type SQLitePostData (conn : SqliteConnection) =
|
|||||||
let findById postId webLogId = backgroundTask {
|
let findById postId webLogId = backgroundTask {
|
||||||
match! findPostById postId webLogId with
|
match! findPostById postId webLogId with
|
||||||
| Some post ->
|
| Some post ->
|
||||||
let! post = appendPostCategoryTagAndMeta post
|
let! post = appendPostCategoryAndTag post
|
||||||
return Some post
|
return Some post
|
||||||
| None -> return None
|
| None -> return None
|
||||||
}
|
}
|
||||||
@@ -329,7 +233,7 @@ type SQLitePostData (conn : SqliteConnection) =
|
|||||||
cmd.Parameters.AddWithValue ("@link", Permalink.toString permalink) |> ignore
|
cmd.Parameters.AddWithValue ("@link", Permalink.toString permalink) |> ignore
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
if rdr.Read () then
|
if rdr.Read () then
|
||||||
let! post = appendPostCategoryTagAndMeta (Map.toPost rdr)
|
let! post = appendPostCategoryAndTag (toPost rdr)
|
||||||
return Some post
|
return Some post
|
||||||
else
|
else
|
||||||
return None
|
return None
|
||||||
@@ -350,14 +254,13 @@ type SQLitePostData (conn : SqliteConnection) =
|
|||||||
| Some _ ->
|
| Some _ ->
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.Parameters.AddWithValue ("@id", PostId.toString postId) |> ignore
|
cmd.Parameters.AddWithValue ("@id", PostId.toString postId) |> ignore
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <-
|
||||||
DELETE FROM post_revision WHERE post_id = @id;
|
"DELETE FROM post_revision WHERE post_id = @id;
|
||||||
DELETE FROM post_permalink WHERE post_id = @id;
|
DELETE FROM post_permalink WHERE post_id = @id;
|
||||||
DELETE FROM post_meta WHERE post_id = @id;
|
DELETE FROM post_tag WHERE post_id = @id;
|
||||||
DELETE FROM post_episode WHERE post_id = @id;
|
DELETE FROM post_category WHERE post_id = @id;
|
||||||
DELETE FROM post_tag WHERE post_id = @id;
|
DELETE FROM post_comment WHERE post_id = @id;
|
||||||
DELETE FROM post_category WHERE post_id = @id;
|
DELETE FROM post WHERE id = @id"
|
||||||
DELETE FROM post WHERE id = @id"""
|
|
||||||
do! write cmd
|
do! write cmd
|
||||||
return true
|
return true
|
||||||
| None -> return false
|
| None -> return false
|
||||||
@@ -366,19 +269,15 @@ type SQLitePostData (conn : SqliteConnection) =
|
|||||||
/// Find the current permalink from a list of potential prior permalinks for the given web log
|
/// Find the current permalink from a list of potential prior permalinks for the given web log
|
||||||
let findCurrentPermalink permalinks webLogId = backgroundTask {
|
let findCurrentPermalink permalinks webLogId = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- """
|
let linkSql, linkParams = inClause "AND pp.permalink" "link" Permalink.toString permalinks
|
||||||
|
cmd.CommandText <- $"
|
||||||
SELECT p.permalink
|
SELECT p.permalink
|
||||||
FROM post p
|
FROM post p
|
||||||
INNER JOIN post_permalink pp ON pp.post_id = p.id
|
INNER JOIN post_permalink pp ON pp.post_id = p.id
|
||||||
WHERE p.web_log_id = @webLogId
|
WHERE p.web_log_id = @webLogId
|
||||||
AND pp.permalink IN ("""
|
{linkSql}"
|
||||||
permalinks
|
|
||||||
|> List.iteri (fun idx link ->
|
|
||||||
if idx > 0 then cmd.CommandText <- $"{cmd.CommandText}, "
|
|
||||||
cmd.CommandText <- $"{cmd.CommandText}@link{idx}"
|
|
||||||
cmd.Parameters.AddWithValue ($"@link{idx}", Permalink.toString link) |> ignore)
|
|
||||||
cmd.CommandText <- $"{cmd.CommandText})"
|
|
||||||
addWebLogId cmd webLogId
|
addWebLogId cmd webLogId
|
||||||
|
cmd.Parameters.AddRange linkParams
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
return if rdr.Read () then Some (Map.toPermalink rdr) else None
|
return if rdr.Read () then Some (Map.toPermalink rdr) else None
|
||||||
}
|
}
|
||||||
@@ -390,9 +289,9 @@ type SQLitePostData (conn : SqliteConnection) =
|
|||||||
addWebLogId cmd webLogId
|
addWebLogId cmd webLogId
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
let! posts =
|
let! posts =
|
||||||
toList Map.toPost rdr
|
toList toPost rdr
|
||||||
|> List.map (fun post -> backgroundTask {
|
|> List.map (fun post -> backgroundTask {
|
||||||
let! post = appendPostCategoryTagAndMeta post
|
let! post = appendPostCategoryAndTag post
|
||||||
return! appendPostRevisionsAndPermalinks post
|
return! appendPostRevisionsAndPermalinks post
|
||||||
})
|
})
|
||||||
|> Task.WhenAll
|
|> Task.WhenAll
|
||||||
@@ -402,27 +301,22 @@ type SQLitePostData (conn : SqliteConnection) =
|
|||||||
/// Get a page of categorized posts for the given web log (excludes revisions and prior permalinks)
|
/// Get a page of categorized posts for the given web log (excludes revisions and prior permalinks)
|
||||||
let findPageOfCategorizedPosts webLogId categoryIds pageNbr postsPerPage = backgroundTask {
|
let findPageOfCategorizedPosts webLogId categoryIds pageNbr postsPerPage = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- $"""
|
let catSql, catParams = inClause "AND pc.category_id" "catId" CategoryId.toString categoryIds
|
||||||
|
cmd.CommandText <- $"
|
||||||
{selectPost}
|
{selectPost}
|
||||||
INNER JOIN post_category pc ON pc.post_id = p.id
|
INNER JOIN post_category pc ON pc.post_id = p.id
|
||||||
WHERE p.web_log_id = @webLogId
|
WHERE p.web_log_id = @webLogId
|
||||||
AND p.status = @status
|
AND p.status = @status
|
||||||
AND pc.category_id IN ("""
|
{catSql}
|
||||||
categoryIds
|
ORDER BY published_on DESC
|
||||||
|> List.iteri (fun idx catId ->
|
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
|
||||||
if idx > 0 then cmd.CommandText <- $"{cmd.CommandText}, "
|
|
||||||
cmd.CommandText <- $"{cmd.CommandText}@catId{idx}"
|
|
||||||
cmd.Parameters.AddWithValue ($"@catId{idx}", CategoryId.toString catId) |> ignore)
|
|
||||||
cmd.CommandText <-
|
|
||||||
$"""{cmd.CommandText})
|
|
||||||
ORDER BY published_on DESC
|
|
||||||
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"""
|
|
||||||
addWebLogId cmd webLogId
|
addWebLogId cmd webLogId
|
||||||
cmd.Parameters.AddWithValue ("@status", PostStatus.toString Published) |> ignore
|
cmd.Parameters.AddWithValue ("@status", PostStatus.toString Published) |> ignore
|
||||||
|
cmd.Parameters.AddRange catParams
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
let! posts =
|
let! posts =
|
||||||
toList Map.toPost rdr
|
toList toPost rdr
|
||||||
|> List.map (fun post -> backgroundTask { return! appendPostCategoryTagAndMeta post })
|
|> List.map (fun post -> backgroundTask { return! appendPostCategoryAndTag post })
|
||||||
|> Task.WhenAll
|
|> Task.WhenAll
|
||||||
return List.ofArray posts
|
return List.ofArray posts
|
||||||
}
|
}
|
||||||
@@ -430,16 +324,16 @@ type SQLitePostData (conn : SqliteConnection) =
|
|||||||
/// Get a page of posts for the given web log (excludes text, revisions, and prior permalinks)
|
/// Get a page of posts for the given web log (excludes text, revisions, and prior permalinks)
|
||||||
let findPageOfPosts webLogId pageNbr postsPerPage = backgroundTask {
|
let findPageOfPosts webLogId pageNbr postsPerPage = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- $"""
|
cmd.CommandText <- $"
|
||||||
{selectPost}
|
{selectPost}
|
||||||
WHERE p.web_log_id = @webLogId
|
WHERE p.web_log_id = @webLogId
|
||||||
ORDER BY p.published_on DESC NULLS FIRST, p.updated_on
|
ORDER BY p.published_on DESC NULLS FIRST, p.updated_on
|
||||||
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"""
|
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
|
||||||
addWebLogId cmd webLogId
|
addWebLogId cmd webLogId
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
let! posts =
|
let! posts =
|
||||||
toList postWithoutText rdr
|
toList postWithoutText rdr
|
||||||
|> List.map (fun post -> backgroundTask { return! appendPostCategoryTagAndMeta post })
|
|> List.map (fun post -> backgroundTask { return! appendPostCategoryAndTag post })
|
||||||
|> Task.WhenAll
|
|> Task.WhenAll
|
||||||
return List.ofArray posts
|
return List.ofArray posts
|
||||||
}
|
}
|
||||||
@@ -447,18 +341,18 @@ type SQLitePostData (conn : SqliteConnection) =
|
|||||||
/// Get a page of published posts for the given web log (excludes revisions and prior permalinks)
|
/// Get a page of published posts for the given web log (excludes revisions and prior permalinks)
|
||||||
let findPageOfPublishedPosts webLogId pageNbr postsPerPage = backgroundTask {
|
let findPageOfPublishedPosts webLogId pageNbr postsPerPage = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- $"""
|
cmd.CommandText <- $"
|
||||||
{selectPost}
|
{selectPost}
|
||||||
WHERE p.web_log_id = @webLogId
|
WHERE p.web_log_id = @webLogId
|
||||||
AND p.status = @status
|
AND p.status = @status
|
||||||
ORDER BY p.published_on DESC
|
ORDER BY p.published_on DESC
|
||||||
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"""
|
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
|
||||||
addWebLogId cmd webLogId
|
addWebLogId cmd webLogId
|
||||||
cmd.Parameters.AddWithValue ("@status", PostStatus.toString Published) |> ignore
|
cmd.Parameters.AddWithValue ("@status", PostStatus.toString Published) |> ignore
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
let! posts =
|
let! posts =
|
||||||
toList Map.toPost rdr
|
toList toPost rdr
|
||||||
|> List.map (fun post -> backgroundTask { return! appendPostCategoryTagAndMeta post })
|
|> List.map (fun post -> backgroundTask { return! appendPostCategoryAndTag post })
|
||||||
|> Task.WhenAll
|
|> Task.WhenAll
|
||||||
return List.ofArray posts
|
return List.ofArray posts
|
||||||
}
|
}
|
||||||
@@ -466,60 +360,60 @@ type SQLitePostData (conn : SqliteConnection) =
|
|||||||
/// Get a page of tagged posts for the given web log (excludes revisions and prior permalinks)
|
/// Get a page of tagged posts for the given web log (excludes revisions and prior permalinks)
|
||||||
let findPageOfTaggedPosts webLogId (tag : string) pageNbr postsPerPage = backgroundTask {
|
let findPageOfTaggedPosts webLogId (tag : string) pageNbr postsPerPage = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- $"""
|
cmd.CommandText <- $"
|
||||||
{selectPost}
|
{selectPost}
|
||||||
INNER JOIN post_tag pt ON pt.post_id = p.id
|
INNER JOIN post_tag pt ON pt.post_id = p.id
|
||||||
WHERE p.web_log_id = @webLogId
|
WHERE p.web_log_id = @webLogId
|
||||||
AND p.status = @status
|
AND p.status = @status
|
||||||
AND pt.tag = @tag
|
AND pt.tag = @tag
|
||||||
ORDER BY p.published_on DESC
|
ORDER BY p.published_on DESC
|
||||||
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"""
|
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
|
||||||
addWebLogId cmd webLogId
|
addWebLogId cmd webLogId
|
||||||
[ cmd.Parameters.AddWithValue ("@status", PostStatus.toString Published)
|
[ cmd.Parameters.AddWithValue ("@status", PostStatus.toString Published)
|
||||||
cmd.Parameters.AddWithValue ("@tag", tag)
|
cmd.Parameters.AddWithValue ("@tag", tag)
|
||||||
] |> ignore
|
] |> ignore
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
let! posts =
|
let! posts =
|
||||||
toList Map.toPost rdr
|
toList toPost rdr
|
||||||
|> List.map (fun post -> backgroundTask { return! appendPostCategoryTagAndMeta post })
|
|> List.map (fun post -> backgroundTask { return! appendPostCategoryAndTag post })
|
||||||
|> Task.WhenAll
|
|> Task.WhenAll
|
||||||
return List.ofArray posts
|
return List.ofArray posts
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find the next newest and oldest post from a publish date for the given web log
|
/// Find the next newest and oldest post from a publish date for the given web log
|
||||||
let findSurroundingPosts webLogId (publishedOn : DateTime) = backgroundTask {
|
let findSurroundingPosts webLogId (publishedOn : Instant) = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- $"""
|
cmd.CommandText <- $"
|
||||||
{selectPost}
|
{selectPost}
|
||||||
WHERE p.web_log_id = @webLogId
|
WHERE p.web_log_id = @webLogId
|
||||||
AND p.status = @status
|
AND p.status = @status
|
||||||
AND p.published_on < @publishedOn
|
AND p.published_on < @publishedOn
|
||||||
ORDER BY p.published_on DESC
|
ORDER BY p.published_on DESC
|
||||||
LIMIT 1"""
|
LIMIT 1"
|
||||||
addWebLogId cmd webLogId
|
addWebLogId cmd webLogId
|
||||||
[ cmd.Parameters.AddWithValue ("@status", PostStatus.toString Published)
|
[ cmd.Parameters.AddWithValue ("@status", PostStatus.toString Published)
|
||||||
cmd.Parameters.AddWithValue ("@publishedOn", publishedOn)
|
cmd.Parameters.AddWithValue ("@publishedOn", instantParam publishedOn)
|
||||||
] |> ignore
|
] |> ignore
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
let! older = backgroundTask {
|
let! older = backgroundTask {
|
||||||
if rdr.Read () then
|
if rdr.Read () then
|
||||||
let! post = appendPostCategoryTagAndMeta (postWithoutText rdr)
|
let! post = appendPostCategoryAndTag (postWithoutText rdr)
|
||||||
return Some post
|
return Some post
|
||||||
else
|
else
|
||||||
return None
|
return None
|
||||||
}
|
}
|
||||||
do! rdr.CloseAsync ()
|
do! rdr.CloseAsync ()
|
||||||
cmd.CommandText <- $"""
|
cmd.CommandText <- $"
|
||||||
{selectPost}
|
{selectPost}
|
||||||
WHERE p.web_log_id = @webLogId
|
WHERE p.web_log_id = @webLogId
|
||||||
AND p.status = @status
|
AND p.status = @status
|
||||||
AND p.published_on > @publishedOn
|
AND p.published_on > @publishedOn
|
||||||
ORDER BY p.published_on
|
ORDER BY p.published_on
|
||||||
LIMIT 1"""
|
LIMIT 1"
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
let! newer = backgroundTask {
|
let! newer = backgroundTask {
|
||||||
if rdr.Read () then
|
if rdr.Read () then
|
||||||
let! post = appendPostCategoryTagAndMeta (postWithoutText rdr)
|
let! post = appendPostCategoryAndTag (postWithoutText rdr)
|
||||||
return Some post
|
return Some post
|
||||||
else
|
else
|
||||||
return None
|
return None
|
||||||
@@ -538,24 +432,24 @@ type SQLitePostData (conn : SqliteConnection) =
|
|||||||
match! findFullById post.Id post.WebLogId with
|
match! findFullById post.Id post.WebLogId with
|
||||||
| Some oldPost ->
|
| Some oldPost ->
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <-
|
||||||
UPDATE post
|
"UPDATE post
|
||||||
SET author_id = @authorId,
|
SET author_id = @authorId,
|
||||||
status = @status,
|
status = @status,
|
||||||
title = @title,
|
title = @title,
|
||||||
permalink = @permalink,
|
permalink = @permalink,
|
||||||
published_on = @publishedOn,
|
published_on = @publishedOn,
|
||||||
updated_on = @updatedOn,
|
updated_on = @updatedOn,
|
||||||
template = @template,
|
template = @template,
|
||||||
post_text = @text
|
post_text = @text,
|
||||||
WHERE id = @id
|
episode = @episode,
|
||||||
AND web_log_id = @webLogId"""
|
meta_items = @metaItems
|
||||||
|
WHERE id = @id
|
||||||
|
AND web_log_id = @webLogId"
|
||||||
addPostParameters cmd post
|
addPostParameters cmd post
|
||||||
do! write cmd
|
do! write cmd
|
||||||
do! updatePostCategories post.Id oldPost.CategoryIds post.CategoryIds
|
do! updatePostCategories post.Id oldPost.CategoryIds post.CategoryIds
|
||||||
do! updatePostTags post.Id oldPost.Tags post.Tags
|
do! updatePostTags post.Id oldPost.Tags post.Tags
|
||||||
do! updatePostEpisode post
|
|
||||||
do! updatePostMeta post.Id oldPost.Metadata post.Metadata
|
|
||||||
do! updatePostPermalinks post.Id oldPost.PriorPermalinks post.PriorPermalinks
|
do! updatePostPermalinks post.Id oldPost.PriorPermalinks post.PriorPermalinks
|
||||||
do! updatePostRevisions post.Id oldPost.Revisions post.Revisions
|
do! updatePostRevisions post.Id oldPost.Revisions post.Revisions
|
||||||
| None -> return ()
|
| None -> return ()
|
||||||
|
|||||||
@@ -50,18 +50,14 @@ type SQLiteTagMapData (conn : SqliteConnection) =
|
|||||||
/// Find any tag mappings in a list of tags for the given web log
|
/// Find any tag mappings in a list of tags for the given web log
|
||||||
let findMappingForTags (tags : string list) webLogId = backgroundTask {
|
let findMappingForTags (tags : string list) webLogId = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- """
|
let mapSql, mapParams = inClause "AND tag" "tag" id tags
|
||||||
|
cmd.CommandText <- $"
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM tag_map
|
FROM tag_map
|
||||||
WHERE web_log_id = @webLogId
|
WHERE web_log_id = @webLogId
|
||||||
AND tag IN ("""
|
{mapSql}"
|
||||||
tags
|
|
||||||
|> List.iteri (fun idx tag ->
|
|
||||||
if idx > 0 then cmd.CommandText <- $"{cmd.CommandText}, "
|
|
||||||
cmd.CommandText <- $"{cmd.CommandText}@tag{idx}"
|
|
||||||
cmd.Parameters.AddWithValue ($"@tag{idx}", tag) |> ignore)
|
|
||||||
cmd.CommandText <- $"{cmd.CommandText})"
|
|
||||||
addWebLogId cmd webLogId
|
addWebLogId cmd webLogId
|
||||||
|
cmd.Parameters.AddRange mapParams
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
return toList Map.toTagMap rdr
|
return toList Map.toTagMap rdr
|
||||||
}
|
}
|
||||||
@@ -71,23 +67,23 @@ type SQLiteTagMapData (conn : SqliteConnection) =
|
|||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
match! findById tagMap.Id tagMap.WebLogId with
|
match! findById tagMap.Id tagMap.WebLogId with
|
||||||
| Some _ ->
|
| Some _ ->
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <-
|
||||||
UPDATE tag_map
|
"UPDATE tag_map
|
||||||
SET tag = @tag,
|
SET tag = @tag,
|
||||||
url_value = @urlValue
|
url_value = @urlValue
|
||||||
WHERE id = @id
|
WHERE id = @id
|
||||||
AND web_log_id = @webLogId"""
|
AND web_log_id = @webLogId"
|
||||||
| None ->
|
| None ->
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <-
|
||||||
INSERT INTO tag_map (
|
"INSERT INTO tag_map (
|
||||||
id, web_log_id, tag, url_value
|
id, web_log_id, tag, url_value
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@id, @webLogId, @tag, @urlValue
|
@id, @webLogId, @tag, @urlValue
|
||||||
)"""
|
)"
|
||||||
addWebLogId cmd tagMap.WebLogId
|
addWebLogId cmd tagMap.WebLogId
|
||||||
[ cmd.Parameters.AddWithValue ("@id", TagMapId.toString tagMap.Id)
|
[ cmd.Parameters.AddWithValue ("@id", TagMapId.toString tagMap.Id)
|
||||||
cmd.Parameters.AddWithValue ("@tag", tagMap.Tag)
|
cmd.Parameters.AddWithValue ("@tag", tagMap.Tag)
|
||||||
cmd.Parameters.AddWithValue ("@urlValue", tagMap.UrlValue)
|
cmd.Parameters.AddWithValue ("@urlValue", tagMap.UrlValue)
|
||||||
] |> ignore
|
] |> ignore
|
||||||
do! write cmd
|
do! write cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,31 @@ open MyWebLog.Data
|
|||||||
/// SQLite myWebLog theme data implementation
|
/// SQLite myWebLog theme data implementation
|
||||||
type SQLiteThemeData (conn : SqliteConnection) =
|
type SQLiteThemeData (conn : SqliteConnection) =
|
||||||
|
|
||||||
/// Retrieve all themes (except 'admin'; excludes templates)
|
/// Retrieve all themes (except 'admin'; excludes template text)
|
||||||
let all () = backgroundTask {
|
let all () = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- "SELECT * FROM theme WHERE id <> 'admin' ORDER BY id"
|
cmd.CommandText <- "SELECT * FROM theme WHERE id <> 'admin' ORDER BY id"
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
return toList Map.toTheme rdr
|
let themes = toList Map.toTheme rdr
|
||||||
|
do! rdr.CloseAsync ()
|
||||||
|
cmd.CommandText <- "SELECT name, theme_id FROM theme_template WHERE theme_id <> 'admin' ORDER BY name"
|
||||||
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
|
let templates =
|
||||||
|
seq { while rdr.Read () do ThemeId (Map.getString "theme_id" rdr), Map.toThemeTemplate false rdr }
|
||||||
|
|> List.ofSeq
|
||||||
|
return
|
||||||
|
themes
|
||||||
|
|> List.map (fun t ->
|
||||||
|
{ t with Templates = templates |> List.filter (fun (themeId, _) -> themeId = t.Id) |> List.map snd })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does a given theme exist?
|
||||||
|
let exists themeId = backgroundTask {
|
||||||
|
use cmd = conn.CreateCommand ()
|
||||||
|
cmd.CommandText <- "SELECT COUNT(id) FROM theme WHERE id = @id"
|
||||||
|
cmd.Parameters.AddWithValue ("@id", ThemeId.toString themeId) |> ignore
|
||||||
|
let! count = count cmd
|
||||||
|
return count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find a theme by its ID
|
/// Find a theme by its ID
|
||||||
@@ -28,7 +47,7 @@ type SQLiteThemeData (conn : SqliteConnection) =
|
|||||||
templateCmd.CommandText <- "SELECT * FROM theme_template WHERE theme_id = @id"
|
templateCmd.CommandText <- "SELECT * FROM theme_template WHERE theme_id = @id"
|
||||||
templateCmd.Parameters.Add cmd.Parameters["@id"] |> ignore
|
templateCmd.Parameters.Add cmd.Parameters["@id"] |> ignore
|
||||||
use! templateRdr = templateCmd.ExecuteReaderAsync ()
|
use! templateRdr = templateCmd.ExecuteReaderAsync ()
|
||||||
return Some { theme with Templates = toList Map.toThemeTemplate templateRdr }
|
return Some { theme with Templates = toList (Map.toThemeTemplate true) templateRdr }
|
||||||
else
|
else
|
||||||
return None
|
return None
|
||||||
}
|
}
|
||||||
@@ -43,6 +62,21 @@ type SQLiteThemeData (conn : SqliteConnection) =
|
|||||||
| None -> return None
|
| None -> return None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete a theme by its ID
|
||||||
|
let delete themeId = backgroundTask {
|
||||||
|
match! findByIdWithoutText themeId with
|
||||||
|
| Some _ ->
|
||||||
|
use cmd = conn.CreateCommand ()
|
||||||
|
cmd.CommandText <-
|
||||||
|
"DELETE FROM theme_asset WHERE theme_id = @id;
|
||||||
|
DELETE FROM theme_template WHERE theme_id = @id;
|
||||||
|
DELETE FROM theme WHERE id = @id"
|
||||||
|
cmd.Parameters.AddWithValue ("@id", ThemeId.toString themeId) |> ignore
|
||||||
|
do! write cmd
|
||||||
|
return true
|
||||||
|
| None -> return false
|
||||||
|
}
|
||||||
|
|
||||||
/// Save a theme
|
/// Save a theme
|
||||||
let save (theme : Theme) = backgroundTask {
|
let save (theme : Theme) = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
@@ -51,15 +85,15 @@ type SQLiteThemeData (conn : SqliteConnection) =
|
|||||||
match oldTheme with
|
match oldTheme with
|
||||||
| Some _ -> "UPDATE theme SET name = @name, version = @version WHERE id = @id"
|
| Some _ -> "UPDATE theme SET name = @name, version = @version WHERE id = @id"
|
||||||
| None -> "INSERT INTO theme VALUES (@id, @name, @version)"
|
| None -> "INSERT INTO theme VALUES (@id, @name, @version)"
|
||||||
[ cmd.Parameters.AddWithValue ("@id", ThemeId.toString theme.Id)
|
[ cmd.Parameters.AddWithValue ("@id", ThemeId.toString theme.Id)
|
||||||
cmd.Parameters.AddWithValue ("@name", theme.Name)
|
cmd.Parameters.AddWithValue ("@name", theme.Name)
|
||||||
cmd.Parameters.AddWithValue ("@version", theme.Version)
|
cmd.Parameters.AddWithValue ("@version", theme.Version)
|
||||||
] |> ignore
|
] |> ignore
|
||||||
do! write cmd
|
do! write cmd
|
||||||
|
|
||||||
let toDelete, toAdd =
|
let toDelete, toAdd =
|
||||||
diffLists (oldTheme |> Option.map (fun t -> t.Templates) |> Option.defaultValue [])
|
Utils.diffLists (oldTheme |> Option.map (fun t -> t.Templates) |> Option.defaultValue [])
|
||||||
theme.Templates (fun t -> t.Name)
|
theme.Templates (fun t -> t.Name)
|
||||||
let toUpdate =
|
let toUpdate =
|
||||||
theme.Templates
|
theme.Templates
|
||||||
|> List.filter (fun t ->
|
|> List.filter (fun t ->
|
||||||
@@ -68,9 +102,9 @@ type SQLiteThemeData (conn : SqliteConnection) =
|
|||||||
cmd.CommandText <-
|
cmd.CommandText <-
|
||||||
"UPDATE theme_template SET template = @template WHERE theme_id = @themeId AND name = @name"
|
"UPDATE theme_template SET template = @template WHERE theme_id = @themeId AND name = @name"
|
||||||
cmd.Parameters.Clear ()
|
cmd.Parameters.Clear ()
|
||||||
[ cmd.Parameters.AddWithValue ("@themeId", ThemeId.toString theme.Id)
|
[ cmd.Parameters.AddWithValue ("@themeId", ThemeId.toString theme.Id)
|
||||||
cmd.Parameters.Add ("@name", SqliteType.Text)
|
cmd.Parameters.Add ("@name", SqliteType.Text)
|
||||||
cmd.Parameters.Add ("@template", SqliteType.Text)
|
cmd.Parameters.Add ("@template", SqliteType.Text)
|
||||||
] |> ignore
|
] |> ignore
|
||||||
toUpdate
|
toUpdate
|
||||||
|> List.map (fun template -> backgroundTask {
|
|> List.map (fun template -> backgroundTask {
|
||||||
@@ -102,6 +136,8 @@ type SQLiteThemeData (conn : SqliteConnection) =
|
|||||||
|
|
||||||
interface IThemeData with
|
interface IThemeData with
|
||||||
member _.All () = all ()
|
member _.All () = all ()
|
||||||
|
member _.Delete themeId = delete themeId
|
||||||
|
member _.Exists themeId = exists themeId
|
||||||
member _.FindById themeId = findById themeId
|
member _.FindById themeId = findById themeId
|
||||||
member _.FindByIdWithoutText themeId = findByIdWithoutText themeId
|
member _.FindByIdWithoutText themeId = findByIdWithoutText themeId
|
||||||
member _.Save theme = save theme
|
member _.Save theme = save theme
|
||||||
@@ -133,8 +169,8 @@ type SQLiteThemeAssetData (conn : SqliteConnection) =
|
|||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- "SELECT *, ROWID FROM theme_asset WHERE theme_id = @themeId AND path = @path"
|
cmd.CommandText <- "SELECT *, ROWID FROM theme_asset WHERE theme_id = @themeId AND path = @path"
|
||||||
let (ThemeAssetId (ThemeId themeId, path)) = assetId
|
let (ThemeAssetId (ThemeId themeId, path)) = assetId
|
||||||
[ cmd.Parameters.AddWithValue ("@themeId", themeId)
|
[ cmd.Parameters.AddWithValue ("@themeId", themeId)
|
||||||
cmd.Parameters.AddWithValue ("@path", path)
|
cmd.Parameters.AddWithValue ("@path", path)
|
||||||
] |> ignore
|
] |> ignore
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
return if rdr.Read () then Some (Map.toThemeAsset true rdr) else None
|
return if rdr.Read () then Some (Map.toThemeAsset true rdr) else None
|
||||||
@@ -164,29 +200,29 @@ type SQLiteThemeAssetData (conn : SqliteConnection) =
|
|||||||
sideCmd.CommandText <-
|
sideCmd.CommandText <-
|
||||||
"SELECT COUNT(path) FROM theme_asset WHERE theme_id = @themeId AND path = @path"
|
"SELECT COUNT(path) FROM theme_asset WHERE theme_id = @themeId AND path = @path"
|
||||||
let (ThemeAssetId (ThemeId themeId, path)) = asset.Id
|
let (ThemeAssetId (ThemeId themeId, path)) = asset.Id
|
||||||
[ sideCmd.Parameters.AddWithValue ("@themeId", themeId)
|
[ sideCmd.Parameters.AddWithValue ("@themeId", themeId)
|
||||||
sideCmd.Parameters.AddWithValue ("@path", path)
|
sideCmd.Parameters.AddWithValue ("@path", path)
|
||||||
] |> ignore
|
] |> ignore
|
||||||
let! exists = count sideCmd
|
let! exists = count sideCmd
|
||||||
|
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <-
|
cmd.CommandText <-
|
||||||
if exists = 1 then
|
if exists = 1 then
|
||||||
"""UPDATE theme_asset
|
"UPDATE theme_asset
|
||||||
SET updated_on = @updatedOn,
|
SET updated_on = @updatedOn,
|
||||||
data = ZEROBLOB(@dataLength)
|
data = ZEROBLOB(@dataLength)
|
||||||
WHERE theme_id = @themeId
|
WHERE theme_id = @themeId
|
||||||
AND path = @path"""
|
AND path = @path"
|
||||||
else
|
else
|
||||||
"""INSERT INTO theme_asset (
|
"INSERT INTO theme_asset (
|
||||||
theme_id, path, updated_on, data
|
theme_id, path, updated_on, data
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@themeId, @path, @updatedOn, ZEROBLOB(@dataLength)
|
@themeId, @path, @updatedOn, ZEROBLOB(@dataLength)
|
||||||
)"""
|
)"
|
||||||
[ cmd.Parameters.AddWithValue ("@themeId", themeId)
|
[ cmd.Parameters.AddWithValue ("@themeId", themeId)
|
||||||
cmd.Parameters.AddWithValue ("@path", path)
|
cmd.Parameters.AddWithValue ("@path", path)
|
||||||
cmd.Parameters.AddWithValue ("@updatedOn", asset.UpdatedOn)
|
cmd.Parameters.AddWithValue ("@updatedOn", instantParam asset.UpdatedOn)
|
||||||
cmd.Parameters.AddWithValue ("@dataLength", asset.Data.Length)
|
cmd.Parameters.AddWithValue ("@dataLength", asset.Data.Length)
|
||||||
] |> ignore
|
] |> ignore
|
||||||
do! write cmd
|
do! write cmd
|
||||||
|
|
||||||
|
|||||||
@@ -10,22 +10,22 @@ type SQLiteUploadData (conn : SqliteConnection) =
|
|||||||
|
|
||||||
/// Add parameters for uploaded file INSERT and UPDATE statements
|
/// Add parameters for uploaded file INSERT and UPDATE statements
|
||||||
let addUploadParameters (cmd : SqliteCommand) (upload : Upload) =
|
let addUploadParameters (cmd : SqliteCommand) (upload : Upload) =
|
||||||
[ cmd.Parameters.AddWithValue ("@id", UploadId.toString upload.Id)
|
[ cmd.Parameters.AddWithValue ("@id", UploadId.toString upload.Id)
|
||||||
cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString upload.WebLogId)
|
cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString upload.WebLogId)
|
||||||
cmd.Parameters.AddWithValue ("@path", Permalink.toString upload.Path)
|
cmd.Parameters.AddWithValue ("@path", Permalink.toString upload.Path)
|
||||||
cmd.Parameters.AddWithValue ("@updatedOn", upload.UpdatedOn)
|
cmd.Parameters.AddWithValue ("@updatedOn", instantParam upload.UpdatedOn)
|
||||||
cmd.Parameters.AddWithValue ("@dataLength", upload.Data.Length)
|
cmd.Parameters.AddWithValue ("@dataLength", upload.Data.Length)
|
||||||
] |> ignore
|
] |> ignore
|
||||||
|
|
||||||
/// Save an uploaded file
|
/// Save an uploaded file
|
||||||
let add upload = backgroundTask {
|
let add upload = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <-
|
||||||
INSERT INTO upload (
|
"INSERT INTO upload (
|
||||||
id, web_log_id, path, updated_on, data
|
id, web_log_id, path, updated_on, data
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@id, @webLogId, @path, @updatedOn, ZEROBLOB(@dataLength)
|
@id, @webLogId, @path, @updatedOn, ZEROBLOB(@dataLength)
|
||||||
)"""
|
)"
|
||||||
addUploadParameters cmd upload
|
addUploadParameters cmd upload
|
||||||
do! write cmd
|
do! write cmd
|
||||||
|
|
||||||
@@ -40,11 +40,11 @@ type SQLiteUploadData (conn : SqliteConnection) =
|
|||||||
/// Delete an uploaded file by its ID
|
/// Delete an uploaded file by its ID
|
||||||
let delete uploadId webLogId = backgroundTask {
|
let delete uploadId webLogId = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <-
|
||||||
SELECT id, web_log_id, path, updated_on
|
"SELECT id, web_log_id, path, updated_on
|
||||||
FROM upload
|
FROM upload
|
||||||
WHERE id = @id
|
WHERE id = @id
|
||||||
AND web_log_id = @webLogId"""
|
AND web_log_id = @webLogId"
|
||||||
addWebLogId cmd webLogId
|
addWebLogId cmd webLogId
|
||||||
cmd.Parameters.AddWithValue ("@id", UploadId.toString uploadId) |> ignore
|
cmd.Parameters.AddWithValue ("@id", UploadId.toString uploadId) |> ignore
|
||||||
let! rdr = cmd.ExecuteReaderAsync ()
|
let! rdr = cmd.ExecuteReaderAsync ()
|
||||||
|
|||||||
@@ -4,81 +4,64 @@ open System.Threading.Tasks
|
|||||||
open Microsoft.Data.Sqlite
|
open Microsoft.Data.Sqlite
|
||||||
open MyWebLog
|
open MyWebLog
|
||||||
open MyWebLog.Data
|
open MyWebLog.Data
|
||||||
|
open Newtonsoft.Json
|
||||||
|
|
||||||
// The web log podcast insert loop is not statically compilable; this is OK
|
// The web log podcast insert loop is not statically compilable; this is OK
|
||||||
#nowarn "3511"
|
#nowarn "3511"
|
||||||
|
|
||||||
/// SQLite myWebLog web log data implementation
|
/// SQLite myWebLog web log data implementation
|
||||||
type SQLiteWebLogData (conn : SqliteConnection) =
|
type SQLiteWebLogData (conn : SqliteConnection, ser : JsonSerializer) =
|
||||||
|
|
||||||
// SUPPORT FUNCTIONS
|
// SUPPORT FUNCTIONS
|
||||||
|
|
||||||
/// Add parameters for web log INSERT or web log/RSS options UPDATE statements
|
/// Add parameters for web log INSERT or web log/RSS options UPDATE statements
|
||||||
let addWebLogRssParameters (cmd : SqliteCommand) (webLog : WebLog) =
|
let addWebLogRssParameters (cmd : SqliteCommand) (webLog : WebLog) =
|
||||||
[ cmd.Parameters.AddWithValue ("@isFeedEnabled", webLog.Rss.IsFeedEnabled)
|
[ cmd.Parameters.AddWithValue ("@isFeedEnabled", webLog.Rss.IsFeedEnabled)
|
||||||
cmd.Parameters.AddWithValue ("@feedName", webLog.Rss.FeedName)
|
cmd.Parameters.AddWithValue ("@feedName", webLog.Rss.FeedName)
|
||||||
cmd.Parameters.AddWithValue ("@itemsInFeed", maybe webLog.Rss.ItemsInFeed)
|
cmd.Parameters.AddWithValue ("@itemsInFeed", maybe webLog.Rss.ItemsInFeed)
|
||||||
cmd.Parameters.AddWithValue ("@isCategoryEnabled", webLog.Rss.IsCategoryEnabled)
|
cmd.Parameters.AddWithValue ("@isCategoryEnabled", webLog.Rss.IsCategoryEnabled)
|
||||||
cmd.Parameters.AddWithValue ("@isTagEnabled", webLog.Rss.IsTagEnabled)
|
cmd.Parameters.AddWithValue ("@isTagEnabled", webLog.Rss.IsTagEnabled)
|
||||||
cmd.Parameters.AddWithValue ("@copyright", maybe webLog.Rss.Copyright)
|
cmd.Parameters.AddWithValue ("@copyright", maybe webLog.Rss.Copyright)
|
||||||
] |> ignore
|
] |> ignore
|
||||||
|
|
||||||
/// Add parameters for web log INSERT or UPDATE statements
|
/// Add parameters for web log INSERT or UPDATE statements
|
||||||
let addWebLogParameters (cmd : SqliteCommand) (webLog : WebLog) =
|
let addWebLogParameters (cmd : SqliteCommand) (webLog : WebLog) =
|
||||||
[ cmd.Parameters.AddWithValue ("@id", WebLogId.toString webLog.Id)
|
[ cmd.Parameters.AddWithValue ("@id", WebLogId.toString webLog.Id)
|
||||||
cmd.Parameters.AddWithValue ("@name", webLog.Name)
|
cmd.Parameters.AddWithValue ("@name", webLog.Name)
|
||||||
cmd.Parameters.AddWithValue ("@slug", webLog.Slug)
|
cmd.Parameters.AddWithValue ("@slug", webLog.Slug)
|
||||||
cmd.Parameters.AddWithValue ("@subtitle", maybe webLog.Subtitle)
|
cmd.Parameters.AddWithValue ("@subtitle", maybe webLog.Subtitle)
|
||||||
cmd.Parameters.AddWithValue ("@defaultPage", webLog.DefaultPage)
|
cmd.Parameters.AddWithValue ("@defaultPage", webLog.DefaultPage)
|
||||||
cmd.Parameters.AddWithValue ("@postsPerPage", webLog.PostsPerPage)
|
cmd.Parameters.AddWithValue ("@postsPerPage", webLog.PostsPerPage)
|
||||||
cmd.Parameters.AddWithValue ("@themeId", ThemeId.toString webLog.ThemeId)
|
cmd.Parameters.AddWithValue ("@themeId", ThemeId.toString webLog.ThemeId)
|
||||||
cmd.Parameters.AddWithValue ("@urlBase", webLog.UrlBase)
|
cmd.Parameters.AddWithValue ("@urlBase", webLog.UrlBase)
|
||||||
cmd.Parameters.AddWithValue ("@timeZone", webLog.TimeZone)
|
cmd.Parameters.AddWithValue ("@timeZone", webLog.TimeZone)
|
||||||
cmd.Parameters.AddWithValue ("@autoHtmx", webLog.AutoHtmx)
|
cmd.Parameters.AddWithValue ("@autoHtmx", webLog.AutoHtmx)
|
||||||
cmd.Parameters.AddWithValue ("@uploads", UploadDestination.toString webLog.Uploads)
|
cmd.Parameters.AddWithValue ("@uploads", UploadDestination.toString webLog.Uploads)
|
||||||
] |> ignore
|
] |> ignore
|
||||||
addWebLogRssParameters cmd webLog
|
addWebLogRssParameters cmd webLog
|
||||||
|
|
||||||
/// Add parameters for custom feed INSERT or UPDATE statements
|
/// Add parameters for custom feed INSERT or UPDATE statements
|
||||||
let addCustomFeedParameters (cmd : SqliteCommand) webLogId (feed : CustomFeed) =
|
let addCustomFeedParameters (cmd : SqliteCommand) webLogId (feed : CustomFeed) =
|
||||||
[ cmd.Parameters.AddWithValue ("@id", CustomFeedId.toString feed.Id)
|
[ cmd.Parameters.AddWithValue ("@id", CustomFeedId.toString feed.Id)
|
||||||
cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString webLogId)
|
cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString webLogId)
|
||||||
cmd.Parameters.AddWithValue ("@source", CustomFeedSource.toString feed.Source)
|
cmd.Parameters.AddWithValue ("@source", CustomFeedSource.toString feed.Source)
|
||||||
cmd.Parameters.AddWithValue ("@path", Permalink.toString feed.Path)
|
cmd.Parameters.AddWithValue ("@path", Permalink.toString feed.Path)
|
||||||
|
cmd.Parameters.AddWithValue ("@podcast", maybe (if Option.isSome feed.Podcast then
|
||||||
|
Some (Utils.serialize ser feed.Podcast)
|
||||||
|
else None))
|
||||||
] |> ignore
|
] |> ignore
|
||||||
|
|
||||||
/// Add parameters for podcast INSERT or UPDATE statements
|
/// Shorthand to map a data reader to a custom feed
|
||||||
let addPodcastParameters (cmd : SqliteCommand) feedId (podcast : PodcastOptions) =
|
let toCustomFeed =
|
||||||
[ cmd.Parameters.AddWithValue ("@feedId", CustomFeedId.toString feedId)
|
Map.toCustomFeed ser
|
||||||
cmd.Parameters.AddWithValue ("@title", podcast.Title)
|
|
||||||
cmd.Parameters.AddWithValue ("@subtitle", maybe podcast.Subtitle)
|
|
||||||
cmd.Parameters.AddWithValue ("@itemsInFeed", podcast.ItemsInFeed)
|
|
||||||
cmd.Parameters.AddWithValue ("@summary", podcast.Summary)
|
|
||||||
cmd.Parameters.AddWithValue ("@displayedAuthor", podcast.DisplayedAuthor)
|
|
||||||
cmd.Parameters.AddWithValue ("@email", podcast.Email)
|
|
||||||
cmd.Parameters.AddWithValue ("@imageUrl", Permalink.toString podcast.ImageUrl)
|
|
||||||
cmd.Parameters.AddWithValue ("@appleCategory", podcast.AppleCategory)
|
|
||||||
cmd.Parameters.AddWithValue ("@appleSubcategory", maybe podcast.AppleSubcategory)
|
|
||||||
cmd.Parameters.AddWithValue ("@explicit", ExplicitRating.toString podcast.Explicit)
|
|
||||||
cmd.Parameters.AddWithValue ("@defaultMediaType", maybe podcast.DefaultMediaType)
|
|
||||||
cmd.Parameters.AddWithValue ("@mediaBaseUrl", maybe podcast.MediaBaseUrl)
|
|
||||||
cmd.Parameters.AddWithValue ("@podcastGuid", maybe podcast.PodcastGuid)
|
|
||||||
cmd.Parameters.AddWithValue ("@fundingUrl", maybe podcast.FundingUrl)
|
|
||||||
cmd.Parameters.AddWithValue ("@fundingText", maybe podcast.FundingText)
|
|
||||||
cmd.Parameters.AddWithValue ("@medium", maybe (podcast.Medium |> Option.map PodcastMedium.toString))
|
|
||||||
] |> ignore
|
|
||||||
|
|
||||||
/// Get the current custom feeds for a web log
|
/// Get the current custom feeds for a web log
|
||||||
let getCustomFeeds (webLog : WebLog) = backgroundTask {
|
let getCustomFeeds (webLog : WebLog) = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <- "SELECT * FROM web_log_feed WHERE web_log_id = @webLogId"
|
||||||
SELECT f.*, p.*
|
|
||||||
FROM web_log_feed f
|
|
||||||
LEFT JOIN web_log_feed_podcast p ON p.feed_id = f.id
|
|
||||||
WHERE f.web_log_id = @webLogId"""
|
|
||||||
addWebLogId cmd webLog.Id
|
addWebLogId cmd webLog.Id
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
return toList Map.toCustomFeed rdr
|
return toList toCustomFeed rdr
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append custom feeds to a web log
|
/// Append custom feeds to a web log
|
||||||
@@ -87,27 +70,10 @@ type SQLiteWebLogData (conn : SqliteConnection) =
|
|||||||
return { webLog with Rss = { webLog.Rss with CustomFeeds = feeds } }
|
return { webLog with Rss = { webLog.Rss with CustomFeeds = feeds } }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a podcast to a custom feed
|
|
||||||
let addPodcast feedId (podcast : PodcastOptions) = backgroundTask {
|
|
||||||
use cmd = conn.CreateCommand ()
|
|
||||||
cmd.CommandText <- """
|
|
||||||
INSERT INTO web_log_feed_podcast (
|
|
||||||
feed_id, title, subtitle, items_in_feed, summary, displayed_author, email, image_url,
|
|
||||||
apple_category, apple_subcategory, explicit, default_media_type, media_base_url, podcast_guid,
|
|
||||||
funding_url, funding_text, medium
|
|
||||||
) VALUES (
|
|
||||||
@feedId, @title, @subtitle, @itemsInFeed, @summary, @displayedAuthor, @email, @imageUrl,
|
|
||||||
@appleCategory, @appleSubcategory, @explicit, @defaultMediaType, @mediaBaseUrl, @podcastGuid,
|
|
||||||
@fundingUrl, @fundingText, @medium
|
|
||||||
)"""
|
|
||||||
addPodcastParameters cmd feedId podcast
|
|
||||||
do! write cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the custom feeds for a web log
|
/// Update the custom feeds for a web log
|
||||||
let updateCustomFeeds (webLog : WebLog) = backgroundTask {
|
let updateCustomFeeds (webLog : WebLog) = backgroundTask {
|
||||||
let! feeds = getCustomFeeds webLog
|
let! feeds = getCustomFeeds webLog
|
||||||
let toDelete, toAdd = diffLists feeds webLog.Rss.CustomFeeds (fun it -> $"{CustomFeedId.toString it.Id}")
|
let toDelete, toAdd = Utils.diffLists feeds webLog.Rss.CustomFeeds (fun it -> $"{CustomFeedId.toString it.Id}")
|
||||||
let toId (feed : CustomFeed) = feed.Id
|
let toId (feed : CustomFeed) = feed.Id
|
||||||
let toUpdate =
|
let toUpdate =
|
||||||
webLog.Rss.CustomFeeds
|
webLog.Rss.CustomFeeds
|
||||||
@@ -117,9 +83,7 @@ type SQLiteWebLogData (conn : SqliteConnection) =
|
|||||||
cmd.Parameters.Add ("@id", SqliteType.Text) |> ignore
|
cmd.Parameters.Add ("@id", SqliteType.Text) |> ignore
|
||||||
toDelete
|
toDelete
|
||||||
|> List.map (fun it -> backgroundTask {
|
|> List.map (fun it -> backgroundTask {
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <- "DELETE FROM web_log_feed WHERE id = @id"
|
||||||
DELETE FROM web_log_feed_podcast WHERE feed_id = @id;
|
|
||||||
DELETE FROM web_log_feed WHERE id = @id"""
|
|
||||||
cmd.Parameters["@id"].Value <- CustomFeedId.toString it.Id
|
cmd.Parameters["@id"].Value <- CustomFeedId.toString it.Id
|
||||||
do! write cmd
|
do! write cmd
|
||||||
})
|
})
|
||||||
@@ -128,68 +92,30 @@ type SQLiteWebLogData (conn : SqliteConnection) =
|
|||||||
cmd.Parameters.Clear ()
|
cmd.Parameters.Clear ()
|
||||||
toAdd
|
toAdd
|
||||||
|> List.map (fun it -> backgroundTask {
|
|> List.map (fun it -> backgroundTask {
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <-
|
||||||
INSERT INTO web_log_feed (
|
"INSERT INTO web_log_feed (
|
||||||
id, web_log_id, source, path
|
id, web_log_id, source, path, podcast
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@id, @webLogId, @source, @path
|
@id, @webLogId, @source, @path, @podcast
|
||||||
)"""
|
)"
|
||||||
cmd.Parameters.Clear ()
|
cmd.Parameters.Clear ()
|
||||||
addCustomFeedParameters cmd webLog.Id it
|
addCustomFeedParameters cmd webLog.Id it
|
||||||
do! write cmd
|
do! write cmd
|
||||||
match it.Podcast with
|
|
||||||
| Some podcast -> do! addPodcast it.Id podcast
|
|
||||||
| None -> ()
|
|
||||||
})
|
})
|
||||||
|> Task.WhenAll
|
|> Task.WhenAll
|
||||||
|> ignore
|
|> ignore
|
||||||
toUpdate
|
toUpdate
|
||||||
|> List.map (fun it -> backgroundTask {
|
|> List.map (fun it -> backgroundTask {
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <-
|
||||||
UPDATE web_log_feed
|
"UPDATE web_log_feed
|
||||||
SET source = @source,
|
SET source = @source,
|
||||||
path = @path
|
path = @path,
|
||||||
WHERE id = @id
|
podcast = @podcast
|
||||||
AND web_log_id = @webLogId"""
|
WHERE id = @id
|
||||||
|
AND web_log_id = @webLogId"
|
||||||
cmd.Parameters.Clear ()
|
cmd.Parameters.Clear ()
|
||||||
addCustomFeedParameters cmd webLog.Id it
|
addCustomFeedParameters cmd webLog.Id it
|
||||||
do! write cmd
|
do! write cmd
|
||||||
let hadPodcast = Option.isSome (feeds |> List.find (fun f -> f.Id = it.Id)).Podcast
|
|
||||||
match it.Podcast with
|
|
||||||
| Some podcast ->
|
|
||||||
if hadPodcast then
|
|
||||||
cmd.CommandText <- """
|
|
||||||
UPDATE web_log_feed_podcast
|
|
||||||
SET title = @title,
|
|
||||||
subtitle = @subtitle,
|
|
||||||
items_in_feed = @itemsInFeed,
|
|
||||||
summary = @summary,
|
|
||||||
displayed_author = @displayedAuthor,
|
|
||||||
email = @email,
|
|
||||||
image_url = @imageUrl,
|
|
||||||
apple_category = @appleCategory,
|
|
||||||
apple_subcategory = @appleSubcategory,
|
|
||||||
explicit = @explicit,
|
|
||||||
default_media_type = @defaultMediaType,
|
|
||||||
media_base_url = @mediaBaseUrl,
|
|
||||||
podcast_guid = @podcastGuid,
|
|
||||||
funding_url = @fundingUrl,
|
|
||||||
funding_text = @fundingText,
|
|
||||||
medium = @medium
|
|
||||||
WHERE feed_id = @feedId"""
|
|
||||||
cmd.Parameters.Clear ()
|
|
||||||
addPodcastParameters cmd it.Id podcast
|
|
||||||
do! write cmd
|
|
||||||
else
|
|
||||||
do! addPodcast it.Id podcast
|
|
||||||
| None ->
|
|
||||||
if hadPodcast then
|
|
||||||
cmd.CommandText <- "DELETE FROM web_log_feed_podcast WHERE feed_id = @id"
|
|
||||||
cmd.Parameters.Clear ()
|
|
||||||
cmd.Parameters.AddWithValue ("@id", CustomFeedId.toString it.Id) |> ignore
|
|
||||||
do! write cmd
|
|
||||||
else
|
|
||||||
()
|
|
||||||
})
|
})
|
||||||
|> Task.WhenAll
|
|> Task.WhenAll
|
||||||
|> ignore
|
|> ignore
|
||||||
@@ -200,14 +126,14 @@ type SQLiteWebLogData (conn : SqliteConnection) =
|
|||||||
/// Add a web log
|
/// Add a web log
|
||||||
let add webLog = backgroundTask {
|
let add webLog = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <-
|
||||||
INSERT INTO web_log (
|
"INSERT INTO web_log (
|
||||||
id, name, slug, subtitle, default_page, posts_per_page, theme_id, url_base, time_zone, auto_htmx,
|
id, name, slug, subtitle, default_page, posts_per_page, theme_id, url_base, time_zone, auto_htmx,
|
||||||
uploads, is_feed_enabled, feed_name, items_in_feed, is_category_enabled, is_tag_enabled, copyright
|
uploads, is_feed_enabled, feed_name, items_in_feed, is_category_enabled, is_tag_enabled, copyright
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@id, @name, @slug, @subtitle, @defaultPage, @postsPerPage, @themeId, @urlBase, @timeZone, @autoHtmx,
|
@id, @name, @slug, @subtitle, @defaultPage, @postsPerPage, @themeId, @urlBase, @timeZone, @autoHtmx,
|
||||||
@uploads, @isFeedEnabled, @feedName, @itemsInFeed, @isCategoryEnabled, @isTagEnabled, @copyright
|
@uploads, @isFeedEnabled, @feedName, @itemsInFeed, @isCategoryEnabled, @isTagEnabled, @copyright
|
||||||
)"""
|
)"
|
||||||
addWebLogParameters cmd webLog
|
addWebLogParameters cmd webLog
|
||||||
do! write cmd
|
do! write cmd
|
||||||
do! updateCustomFeeds webLog
|
do! updateCustomFeeds webLog
|
||||||
@@ -232,26 +158,22 @@ type SQLiteWebLogData (conn : SqliteConnection) =
|
|||||||
let subQuery table = $"(SELECT id FROM {table} WHERE web_log_id = @webLogId)"
|
let subQuery table = $"(SELECT id FROM {table} WHERE web_log_id = @webLogId)"
|
||||||
let postSubQuery = subQuery "post"
|
let postSubQuery = subQuery "post"
|
||||||
let pageSubQuery = subQuery "page"
|
let pageSubQuery = subQuery "page"
|
||||||
cmd.CommandText <- $"""
|
cmd.CommandText <- $"
|
||||||
DELETE FROM post_comment WHERE post_id IN {postSubQuery};
|
DELETE FROM post_comment WHERE post_id IN {postSubQuery};
|
||||||
DELETE FROM post_revision WHERE post_id IN {postSubQuery};
|
DELETE FROM post_revision WHERE post_id IN {postSubQuery};
|
||||||
DELETE FROM post_permalink WHERE post_id IN {postSubQuery};
|
DELETE FROM post_permalink WHERE post_id IN {postSubQuery};
|
||||||
DELETE FROM post_episode WHERE post_id IN {postSubQuery};
|
DELETE FROM post_tag WHERE post_id IN {postSubQuery};
|
||||||
DELETE FROM post_tag WHERE post_id IN {postSubQuery};
|
DELETE FROM post_category WHERE post_id IN {postSubQuery};
|
||||||
DELETE FROM post_category WHERE post_id IN {postSubQuery};
|
DELETE FROM post WHERE web_log_id = @webLogId;
|
||||||
DELETE FROM post_meta WHERE post_id IN {postSubQuery};
|
DELETE FROM page_revision WHERE page_id IN {pageSubQuery};
|
||||||
DELETE FROM post WHERE web_log_id = @webLogId;
|
DELETE FROM page_permalink WHERE page_id IN {pageSubQuery};
|
||||||
DELETE FROM page_revision WHERE page_id IN {pageSubQuery};
|
DELETE FROM page WHERE web_log_id = @webLogId;
|
||||||
DELETE FROM page_permalink WHERE page_id IN {pageSubQuery};
|
DELETE FROM category WHERE web_log_id = @webLogId;
|
||||||
DELETE FROM page_meta WHERE page_id IN {pageSubQuery};
|
DELETE FROM tag_map WHERE web_log_id = @webLogId;
|
||||||
DELETE FROM page WHERE web_log_id = @webLogId;
|
DELETE FROM upload WHERE web_log_id = @webLogId;
|
||||||
DELETE FROM category WHERE web_log_id = @webLogId;
|
DELETE FROM web_log_user WHERE web_log_id = @webLogId;
|
||||||
DELETE FROM tag_map WHERE web_log_id = @webLogId;
|
DELETE FROM web_log_feed WHERE web_log_id = @webLogId;
|
||||||
DELETE FROM upload WHERE web_log_id = @webLogId;
|
DELETE FROM web_log WHERE id = @webLogId"
|
||||||
DELETE FROM web_log_user WHERE web_log_id = @webLogId;
|
|
||||||
DELETE FROM web_log_feed_podcast WHERE feed_id IN {subQuery "web_log_feed"};
|
|
||||||
DELETE FROM web_log_feed WHERE web_log_id = @webLogId;
|
|
||||||
DELETE FROM web_log WHERE id = @webLogId"""
|
|
||||||
do! write cmd
|
do! write cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,25 +206,25 @@ type SQLiteWebLogData (conn : SqliteConnection) =
|
|||||||
/// Update settings for a web log
|
/// Update settings for a web log
|
||||||
let updateSettings webLog = backgroundTask {
|
let updateSettings webLog = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <-
|
||||||
UPDATE web_log
|
"UPDATE web_log
|
||||||
SET name = @name,
|
SET name = @name,
|
||||||
slug = @slug,
|
slug = @slug,
|
||||||
subtitle = @subtitle,
|
subtitle = @subtitle,
|
||||||
default_page = @defaultPage,
|
default_page = @defaultPage,
|
||||||
posts_per_page = @postsPerPage,
|
posts_per_page = @postsPerPage,
|
||||||
theme_id = @themeId,
|
theme_id = @themeId,
|
||||||
url_base = @urlBase,
|
url_base = @urlBase,
|
||||||
time_zone = @timeZone,
|
time_zone = @timeZone,
|
||||||
auto_htmx = @autoHtmx,
|
auto_htmx = @autoHtmx,
|
||||||
uploads = @uploads,
|
uploads = @uploads,
|
||||||
is_feed_enabled = @isFeedEnabled,
|
is_feed_enabled = @isFeedEnabled,
|
||||||
feed_name = @feedName,
|
feed_name = @feedName,
|
||||||
items_in_feed = @itemsInFeed,
|
items_in_feed = @itemsInFeed,
|
||||||
is_category_enabled = @isCategoryEnabled,
|
is_category_enabled = @isCategoryEnabled,
|
||||||
is_tag_enabled = @isTagEnabled,
|
is_tag_enabled = @isTagEnabled,
|
||||||
copyright = @copyright
|
copyright = @copyright
|
||||||
WHERE id = @id"""
|
WHERE id = @id"
|
||||||
addWebLogParameters cmd webLog
|
addWebLogParameters cmd webLog
|
||||||
do! write cmd
|
do! write cmd
|
||||||
}
|
}
|
||||||
@@ -310,16 +232,17 @@ type SQLiteWebLogData (conn : SqliteConnection) =
|
|||||||
/// Update RSS options for a web log
|
/// Update RSS options for a web log
|
||||||
let updateRssOptions webLog = backgroundTask {
|
let updateRssOptions webLog = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <-
|
||||||
UPDATE web_log
|
"UPDATE web_log
|
||||||
SET is_feed_enabled = @isFeedEnabled,
|
SET is_feed_enabled = @isFeedEnabled,
|
||||||
feed_name = @feedName,
|
feed_name = @feedName,
|
||||||
items_in_feed = @itemsInFeed,
|
items_in_feed = @itemsInFeed,
|
||||||
is_category_enabled = @isCategoryEnabled,
|
is_category_enabled = @isCategoryEnabled,
|
||||||
is_tag_enabled = @isTagEnabled,
|
is_tag_enabled = @isTagEnabled,
|
||||||
copyright = @copyright
|
copyright = @copyright
|
||||||
WHERE id = @id"""
|
WHERE id = @id"
|
||||||
addWebLogRssParameters cmd webLog
|
addWebLogRssParameters cmd webLog
|
||||||
|
cmd.Parameters.AddWithValue ("@id", WebLogId.toString webLog.Id) |> ignore
|
||||||
do! write cmd
|
do! write cmd
|
||||||
do! updateCustomFeeds webLog
|
do! updateCustomFeeds webLog
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
namespace MyWebLog.Data.SQLite
|
namespace MyWebLog.Data.SQLite
|
||||||
|
|
||||||
open System
|
|
||||||
open Microsoft.Data.Sqlite
|
open Microsoft.Data.Sqlite
|
||||||
open MyWebLog
|
open MyWebLog
|
||||||
open MyWebLog.Data
|
open MyWebLog.Data
|
||||||
@@ -12,18 +11,17 @@ type SQLiteWebLogUserData (conn : SqliteConnection) =
|
|||||||
|
|
||||||
/// Add parameters for web log user INSERT or UPDATE statements
|
/// Add parameters for web log user INSERT or UPDATE statements
|
||||||
let addWebLogUserParameters (cmd : SqliteCommand) (user : WebLogUser) =
|
let addWebLogUserParameters (cmd : SqliteCommand) (user : WebLogUser) =
|
||||||
[ cmd.Parameters.AddWithValue ("@id", WebLogUserId.toString user.Id)
|
[ cmd.Parameters.AddWithValue ("@id", WebLogUserId.toString user.Id)
|
||||||
cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString user.WebLogId)
|
cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString user.WebLogId)
|
||||||
cmd.Parameters.AddWithValue ("@email", user.Email)
|
cmd.Parameters.AddWithValue ("@email", user.Email)
|
||||||
cmd.Parameters.AddWithValue ("@firstName", user.FirstName)
|
cmd.Parameters.AddWithValue ("@firstName", user.FirstName)
|
||||||
cmd.Parameters.AddWithValue ("@lastName", user.LastName)
|
cmd.Parameters.AddWithValue ("@lastName", user.LastName)
|
||||||
cmd.Parameters.AddWithValue ("@preferredName", user.PreferredName)
|
cmd.Parameters.AddWithValue ("@preferredName", user.PreferredName)
|
||||||
cmd.Parameters.AddWithValue ("@passwordHash", user.PasswordHash)
|
cmd.Parameters.AddWithValue ("@passwordHash", user.PasswordHash)
|
||||||
cmd.Parameters.AddWithValue ("@salt", user.Salt)
|
cmd.Parameters.AddWithValue ("@url", maybe user.Url)
|
||||||
cmd.Parameters.AddWithValue ("@url", maybe user.Url)
|
cmd.Parameters.AddWithValue ("@accessLevel", AccessLevel.toString user.AccessLevel)
|
||||||
cmd.Parameters.AddWithValue ("@accessLevel", AccessLevel.toString user.AccessLevel)
|
cmd.Parameters.AddWithValue ("@createdOn", instantParam user.CreatedOn)
|
||||||
cmd.Parameters.AddWithValue ("@createdOn", user.CreatedOn)
|
cmd.Parameters.AddWithValue ("@lastSeenOn", maybeInstant user.LastSeenOn)
|
||||||
cmd.Parameters.AddWithValue ("@lastSeenOn", maybe user.LastSeenOn)
|
|
||||||
] |> ignore
|
] |> ignore
|
||||||
|
|
||||||
// IMPLEMENTATION FUNCTIONS
|
// IMPLEMENTATION FUNCTIONS
|
||||||
@@ -31,14 +29,14 @@ type SQLiteWebLogUserData (conn : SqliteConnection) =
|
|||||||
/// Add a user
|
/// Add a user
|
||||||
let add user = backgroundTask {
|
let add user = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <-
|
||||||
INSERT INTO web_log_user (
|
"INSERT INTO web_log_user (
|
||||||
id, web_log_id, email, first_name, last_name, preferred_name, password_hash, salt, url, access_level,
|
id, web_log_id, email, first_name, last_name, preferred_name, password_hash, url, access_level,
|
||||||
created_on, last_seen_on
|
created_on, last_seen_on
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@id, @webLogId, @email, @firstName, @lastName, @preferredName, @passwordHash, @salt, @url, @accessLevel,
|
@id, @webLogId, @email, @firstName, @lastName, @preferredName, @passwordHash, @url, @accessLevel,
|
||||||
@createdOn, @lastSeenOn
|
@createdOn, @lastSeenOn
|
||||||
)"""
|
)"
|
||||||
addWebLogUserParameters cmd user
|
addWebLogUserParameters cmd user
|
||||||
do! write cmd
|
do! write cmd
|
||||||
}
|
}
|
||||||
@@ -93,14 +91,10 @@ type SQLiteWebLogUserData (conn : SqliteConnection) =
|
|||||||
/// Find the names of users by their IDs for the given web log
|
/// Find the names of users by their IDs for the given web log
|
||||||
let findNames webLogId userIds = backgroundTask {
|
let findNames webLogId userIds = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- "SELECT * FROM web_log_user WHERE web_log_id = @webLogId AND id IN ("
|
let nameSql, nameParams = inClause "AND id" "id" WebLogUserId.toString userIds
|
||||||
userIds
|
cmd.CommandText <- $"SELECT * FROM web_log_user WHERE web_log_id = @webLogId {nameSql}"
|
||||||
|> List.iteri (fun idx userId ->
|
|
||||||
if idx > 0 then cmd.CommandText <- $"{cmd.CommandText}, "
|
|
||||||
cmd.CommandText <- $"{cmd.CommandText}@id{idx}"
|
|
||||||
cmd.Parameters.AddWithValue ($"@id{idx}", WebLogUserId.toString userId) |> ignore)
|
|
||||||
cmd.CommandText <- $"{cmd.CommandText})"
|
|
||||||
addWebLogId cmd webLogId
|
addWebLogId cmd webLogId
|
||||||
|
cmd.Parameters.AddRange nameParams
|
||||||
use! rdr = cmd.ExecuteReaderAsync ()
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
return
|
return
|
||||||
toList Map.toWebLogUser rdr
|
toList Map.toWebLogUser rdr
|
||||||
@@ -116,14 +110,14 @@ type SQLiteWebLogUserData (conn : SqliteConnection) =
|
|||||||
/// Set a user's last seen date/time to now
|
/// Set a user's last seen date/time to now
|
||||||
let setLastSeen userId webLogId = backgroundTask {
|
let setLastSeen userId webLogId = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <-
|
||||||
UPDATE web_log_user
|
"UPDATE web_log_user
|
||||||
SET last_seen_on = @lastSeenOn
|
SET last_seen_on = @lastSeenOn
|
||||||
WHERE id = @id
|
WHERE id = @id
|
||||||
AND web_log_id = @webLogId"""
|
AND web_log_id = @webLogId"
|
||||||
addWebLogId cmd webLogId
|
addWebLogId cmd webLogId
|
||||||
[ cmd.Parameters.AddWithValue ("@id", WebLogUserId.toString userId)
|
[ cmd.Parameters.AddWithValue ("@id", WebLogUserId.toString userId)
|
||||||
cmd.Parameters.AddWithValue ("@lastSeenOn", DateTime.UtcNow)
|
cmd.Parameters.AddWithValue ("@lastSeenOn", instantParam (Noda.now ()))
|
||||||
] |> ignore
|
] |> ignore
|
||||||
let! _ = cmd.ExecuteNonQueryAsync ()
|
let! _ = cmd.ExecuteNonQueryAsync ()
|
||||||
()
|
()
|
||||||
@@ -132,20 +126,19 @@ type SQLiteWebLogUserData (conn : SqliteConnection) =
|
|||||||
/// Update a user
|
/// Update a user
|
||||||
let update user = backgroundTask {
|
let update user = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- """
|
cmd.CommandText <-
|
||||||
UPDATE web_log_user
|
"UPDATE web_log_user
|
||||||
SET email = @email,
|
SET email = @email,
|
||||||
first_name = @firstName,
|
first_name = @firstName,
|
||||||
last_name = @lastName,
|
last_name = @lastName,
|
||||||
preferred_name = @preferredName,
|
preferred_name = @preferredName,
|
||||||
password_hash = @passwordHash,
|
password_hash = @passwordHash,
|
||||||
salt = @salt,
|
url = @url,
|
||||||
url = @url,
|
access_level = @accessLevel,
|
||||||
access_level = @accessLevel,
|
created_on = @createdOn,
|
||||||
created_on = @createdOn,
|
last_seen_on = @lastSeenOn
|
||||||
last_seen_on = @lastSeenOn
|
WHERE id = @id
|
||||||
WHERE id = @id
|
AND web_log_id = @webLogId"
|
||||||
AND web_log_id = @webLogId"""
|
|
||||||
addWebLogUserParameters cmd user
|
addWebLogUserParameters cmd user
|
||||||
do! write cmd
|
do! write cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,545 @@ namespace MyWebLog.Data
|
|||||||
|
|
||||||
open Microsoft.Data.Sqlite
|
open Microsoft.Data.Sqlite
|
||||||
open Microsoft.Extensions.Logging
|
open Microsoft.Extensions.Logging
|
||||||
|
open MyWebLog
|
||||||
open MyWebLog.Data.SQLite
|
open MyWebLog.Data.SQLite
|
||||||
|
open Newtonsoft.Json
|
||||||
|
open NodaTime
|
||||||
|
|
||||||
/// SQLite myWebLog data implementation
|
/// SQLite myWebLog data implementation
|
||||||
type SQLiteData (conn : SqliteConnection, log : ILogger<SQLiteData>) =
|
type SQLiteData (conn : SqliteConnection, log : ILogger<SQLiteData>, ser : JsonSerializer) =
|
||||||
|
|
||||||
/// Determine if the given table exists
|
let ensureTables () = backgroundTask {
|
||||||
let tableExists (table : string) = backgroundTask {
|
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
cmd.CommandText <- "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = @table"
|
|
||||||
cmd.Parameters.AddWithValue ("@table", table) |> ignore
|
let! tables = backgroundTask {
|
||||||
let! count = count cmd
|
cmd.CommandText <- "SELECT name FROM sqlite_master WHERE type = 'table'"
|
||||||
return count = 1
|
let! rdr = cmd.ExecuteReaderAsync ()
|
||||||
|
let mutable tableList = []
|
||||||
|
while rdr.Read() do
|
||||||
|
tableList <- Map.getString "name" rdr :: tableList
|
||||||
|
do! rdr.CloseAsync ()
|
||||||
|
return tableList
|
||||||
|
}
|
||||||
|
let needsTable table =
|
||||||
|
not (List.contains table tables)
|
||||||
|
seq {
|
||||||
|
// Theme tables
|
||||||
|
if needsTable "theme" then
|
||||||
|
"CREATE TABLE theme (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
version TEXT NOT NULL)"
|
||||||
|
if needsTable "theme_template" then
|
||||||
|
"CREATE TABLE theme_template (
|
||||||
|
theme_id TEXT NOT NULL REFERENCES theme (id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
template TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (theme_id, name))"
|
||||||
|
if needsTable "theme_asset" then
|
||||||
|
"CREATE TABLE theme_asset (
|
||||||
|
theme_id TEXT NOT NULL REFERENCES theme (id),
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
updated_on TEXT NOT NULL,
|
||||||
|
data BLOB NOT NULL,
|
||||||
|
PRIMARY KEY (theme_id, path))"
|
||||||
|
|
||||||
|
// Web log tables
|
||||||
|
if needsTable "web_log" then
|
||||||
|
"CREATE TABLE web_log (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
subtitle TEXT,
|
||||||
|
default_page TEXT NOT NULL,
|
||||||
|
posts_per_page INTEGER NOT NULL,
|
||||||
|
theme_id TEXT NOT NULL REFERENCES theme (id),
|
||||||
|
url_base TEXT NOT NULL,
|
||||||
|
time_zone TEXT NOT NULL,
|
||||||
|
auto_htmx INTEGER NOT NULL DEFAULT 0,
|
||||||
|
uploads TEXT NOT NULL,
|
||||||
|
is_feed_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
feed_name TEXT NOT NULL,
|
||||||
|
items_in_feed INTEGER,
|
||||||
|
is_category_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_tag_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
copyright TEXT);
|
||||||
|
CREATE INDEX web_log_theme_idx ON web_log (theme_id)"
|
||||||
|
if needsTable "web_log_feed" then
|
||||||
|
"CREATE TABLE web_log_feed (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
web_log_id TEXT NOT NULL REFERENCES web_log (id),
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
podcast TEXT);
|
||||||
|
CREATE INDEX web_log_feed_web_log_idx ON web_log_feed (web_log_id)"
|
||||||
|
|
||||||
|
// Category table
|
||||||
|
if needsTable "category" then
|
||||||
|
"CREATE TABLE category (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
web_log_id TEXT NOT NULL REFERENCES web_log (id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
parent_id TEXT);
|
||||||
|
CREATE INDEX category_web_log_idx ON category (web_log_id)"
|
||||||
|
|
||||||
|
// Web log user table
|
||||||
|
if needsTable "web_log_user" then
|
||||||
|
"CREATE TABLE web_log_user (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
web_log_id TEXT NOT NULL REFERENCES web_log (id),
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
first_name TEXT NOT NULL,
|
||||||
|
last_name TEXT NOT NULL,
|
||||||
|
preferred_name TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
url TEXT,
|
||||||
|
access_level TEXT NOT NULL,
|
||||||
|
created_on TEXT NOT NULL,
|
||||||
|
last_seen_on TEXT);
|
||||||
|
CREATE INDEX web_log_user_web_log_idx ON web_log_user (web_log_id);
|
||||||
|
CREATE INDEX web_log_user_email_idx ON web_log_user (web_log_id, email)"
|
||||||
|
|
||||||
|
// Page tables
|
||||||
|
if needsTable "page" then
|
||||||
|
"CREATE TABLE page (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
web_log_id TEXT NOT NULL REFERENCES web_log (id),
|
||||||
|
author_id TEXT NOT NULL REFERENCES web_log_user (id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
permalink TEXT NOT NULL,
|
||||||
|
published_on TEXT NOT NULL,
|
||||||
|
updated_on TEXT NOT NULL,
|
||||||
|
is_in_page_list INTEGER NOT NULL DEFAULT 0,
|
||||||
|
template TEXT,
|
||||||
|
page_text TEXT NOT NULL,
|
||||||
|
meta_items TEXT);
|
||||||
|
CREATE INDEX page_web_log_idx ON page (web_log_id);
|
||||||
|
CREATE INDEX page_author_idx ON page (author_id);
|
||||||
|
CREATE INDEX page_permalink_idx ON page (web_log_id, permalink)"
|
||||||
|
if needsTable "page_permalink" then
|
||||||
|
"CREATE TABLE page_permalink (
|
||||||
|
page_id TEXT NOT NULL REFERENCES page (id),
|
||||||
|
permalink TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (page_id, permalink))"
|
||||||
|
if needsTable "page_revision" then
|
||||||
|
"CREATE TABLE page_revision (
|
||||||
|
page_id TEXT NOT NULL REFERENCES page (id),
|
||||||
|
as_of TEXT NOT NULL,
|
||||||
|
revision_text TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (page_id, as_of))"
|
||||||
|
|
||||||
|
// Post tables
|
||||||
|
if needsTable "post" then
|
||||||
|
"CREATE TABLE post (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
web_log_id TEXT NOT NULL REFERENCES web_log (id),
|
||||||
|
author_id TEXT NOT NULL REFERENCES web_log_user (id),
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
permalink TEXT NOT NULL,
|
||||||
|
published_on TEXT,
|
||||||
|
updated_on TEXT NOT NULL,
|
||||||
|
template TEXT,
|
||||||
|
post_text TEXT NOT NULL,
|
||||||
|
meta_items TEXT,
|
||||||
|
episode TEXT);
|
||||||
|
CREATE INDEX post_web_log_idx ON post (web_log_id);
|
||||||
|
CREATE INDEX post_author_idx ON post (author_id);
|
||||||
|
CREATE INDEX post_status_idx ON post (web_log_id, status, updated_on);
|
||||||
|
CREATE INDEX post_permalink_idx ON post (web_log_id, permalink)"
|
||||||
|
if needsTable "post_category" then
|
||||||
|
"CREATE TABLE post_category (
|
||||||
|
post_id TEXT NOT NULL REFERENCES post (id),
|
||||||
|
category_id TEXT NOT NULL REFERENCES category (id),
|
||||||
|
PRIMARY KEY (post_id, category_id));
|
||||||
|
CREATE INDEX post_category_category_idx ON post_category (category_id)"
|
||||||
|
if needsTable "post_tag" then
|
||||||
|
"CREATE TABLE post_tag (
|
||||||
|
post_id TEXT NOT NULL REFERENCES post (id),
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (post_id, tag))"
|
||||||
|
if needsTable "post_permalink" then
|
||||||
|
"CREATE TABLE post_permalink (
|
||||||
|
post_id TEXT NOT NULL REFERENCES post (id),
|
||||||
|
permalink TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (post_id, permalink))"
|
||||||
|
if needsTable "post_revision" then
|
||||||
|
"CREATE TABLE post_revision (
|
||||||
|
post_id TEXT NOT NULL REFERENCES post (id),
|
||||||
|
as_of TEXT NOT NULL,
|
||||||
|
revision_text TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (post_id, as_of))"
|
||||||
|
if needsTable "post_comment" then
|
||||||
|
"CREATE TABLE post_comment (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
post_id TEXT NOT NULL REFERENCES post(id),
|
||||||
|
in_reply_to_id TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
url TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
posted_on TEXT NOT NULL,
|
||||||
|
comment_text TEXT NOT NULL);
|
||||||
|
CREATE INDEX post_comment_post_idx ON post_comment (post_id)"
|
||||||
|
|
||||||
|
// Tag map table
|
||||||
|
if needsTable "tag_map" then
|
||||||
|
"CREATE TABLE tag_map (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
web_log_id TEXT NOT NULL REFERENCES web_log (id),
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
url_value TEXT NOT NULL);
|
||||||
|
CREATE INDEX tag_map_web_log_idx ON tag_map (web_log_id)"
|
||||||
|
|
||||||
|
// Uploaded file table
|
||||||
|
if needsTable "upload" then
|
||||||
|
"CREATE TABLE upload (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
web_log_id TEXT NOT NULL REFERENCES web_log (id),
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
updated_on TEXT NOT NULL,
|
||||||
|
data BLOB NOT NULL);
|
||||||
|
CREATE INDEX upload_web_log_idx ON upload (web_log_id);
|
||||||
|
CREATE INDEX upload_path_idx ON upload (web_log_id, path)"
|
||||||
|
|
||||||
|
// Database version table
|
||||||
|
if needsTable "db_version" then
|
||||||
|
"CREATE TABLE db_version (id TEXT PRIMARY KEY);
|
||||||
|
INSERT INTO db_version VALUES ('v2-rc1')"
|
||||||
|
}
|
||||||
|
|> Seq.map (fun sql ->
|
||||||
|
log.LogInformation $"Creating {(sql.Split ' ')[2]} table..."
|
||||||
|
cmd.CommandText <- sql
|
||||||
|
write cmd |> Async.AwaitTask |> Async.RunSynchronously)
|
||||||
|
|> List.ofSeq
|
||||||
|
|> ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the database version to the specified version
|
||||||
|
let setDbVersion version = backgroundTask {
|
||||||
|
use cmd = conn.CreateCommand ()
|
||||||
|
cmd.CommandText <- $"DELETE FROM db_version; INSERT INTO db_version VALUES ('%s{version}')"
|
||||||
|
do! write cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implement the changes between v2-rc1 and v2-rc2
|
||||||
|
let migrateV2Rc1ToV2Rc2 () = backgroundTask {
|
||||||
|
let logStep = Utils.logMigrationStep log "v2-rc1 to v2-rc2"
|
||||||
|
// Move meta items, podcast settings, and episode details to JSON-encoded text fields
|
||||||
|
use cmd = conn.CreateCommand ()
|
||||||
|
logStep "Adding new columns"
|
||||||
|
cmd.CommandText <-
|
||||||
|
"ALTER TABLE web_log_feed ADD COLUMN podcast TEXT;
|
||||||
|
ALTER TABLE page ADD COLUMN meta_items TEXT;
|
||||||
|
ALTER TABLE post ADD COLUMN meta_items TEXT;
|
||||||
|
ALTER TABLE post ADD COLUMN episode TEXT"
|
||||||
|
do! write cmd
|
||||||
|
logStep "Migrating meta items"
|
||||||
|
let migrateMeta entity = backgroundTask {
|
||||||
|
cmd.CommandText <- $"SELECT * FROM %s{entity}_meta"
|
||||||
|
use! metaRdr = cmd.ExecuteReaderAsync ()
|
||||||
|
let allMetas =
|
||||||
|
seq {
|
||||||
|
while metaRdr.Read () do
|
||||||
|
Map.getString $"{entity}_id" metaRdr,
|
||||||
|
{ Name = Map.getString "name" metaRdr; Value = Map.getString "value" metaRdr }
|
||||||
|
} |> List.ofSeq
|
||||||
|
metaRdr.Close ()
|
||||||
|
let metas =
|
||||||
|
allMetas
|
||||||
|
|> List.map fst
|
||||||
|
|> List.distinct
|
||||||
|
|> List.map (fun it -> it, allMetas |> List.filter (fun meta -> fst meta = it))
|
||||||
|
metas
|
||||||
|
|> List.iter (fun (entityId, items) ->
|
||||||
|
cmd.CommandText <-
|
||||||
|
"UPDATE post
|
||||||
|
SET meta_items = @metaItems
|
||||||
|
WHERE id = @postId"
|
||||||
|
[ cmd.Parameters.AddWithValue ("@metaItems", Utils.serialize ser items)
|
||||||
|
cmd.Parameters.AddWithValue ("@id", entityId) ] |> ignore
|
||||||
|
let _ = cmd.ExecuteNonQuery ()
|
||||||
|
cmd.Parameters.Clear ())
|
||||||
|
}
|
||||||
|
do! migrateMeta "page"
|
||||||
|
do! migrateMeta "post"
|
||||||
|
logStep "Migrating podcasts and episodes"
|
||||||
|
cmd.CommandText <- "SELECT * FROM web_log_feed_podcast"
|
||||||
|
use! podcastRdr = cmd.ExecuteReaderAsync ()
|
||||||
|
let podcasts =
|
||||||
|
seq {
|
||||||
|
while podcastRdr.Read () do
|
||||||
|
CustomFeedId (Map.getString "feed_id" podcastRdr),
|
||||||
|
{ Title = Map.getString "title" podcastRdr
|
||||||
|
Subtitle = Map.tryString "subtitle" podcastRdr
|
||||||
|
ItemsInFeed = Map.getInt "items_in_feed" podcastRdr
|
||||||
|
Summary = Map.getString "summary" podcastRdr
|
||||||
|
DisplayedAuthor = Map.getString "displayed_author" podcastRdr
|
||||||
|
Email = Map.getString "email" podcastRdr
|
||||||
|
ImageUrl = Map.getString "image_url" podcastRdr |> Permalink
|
||||||
|
AppleCategory = Map.getString "apple_category" podcastRdr
|
||||||
|
AppleSubcategory = Map.tryString "apple_subcategory" podcastRdr
|
||||||
|
Explicit = Map.getString "explicit" podcastRdr |> ExplicitRating.parse
|
||||||
|
DefaultMediaType = Map.tryString "default_media_type" podcastRdr
|
||||||
|
MediaBaseUrl = Map.tryString "media_base_url" podcastRdr
|
||||||
|
PodcastGuid = Map.tryGuid "podcast_guid" podcastRdr
|
||||||
|
FundingUrl = Map.tryString "funding_url" podcastRdr
|
||||||
|
FundingText = Map.tryString "funding_text" podcastRdr
|
||||||
|
Medium = Map.tryString "medium" podcastRdr
|
||||||
|
|> Option.map PodcastMedium.parse
|
||||||
|
}
|
||||||
|
} |> List.ofSeq
|
||||||
|
podcastRdr.Close ()
|
||||||
|
podcasts
|
||||||
|
|> List.iter (fun (feedId, podcast) ->
|
||||||
|
cmd.CommandText <- "UPDATE web_log_feed SET podcast = @podcast WHERE id = @id"
|
||||||
|
[ cmd.Parameters.AddWithValue ("@podcast", Utils.serialize ser podcast)
|
||||||
|
cmd.Parameters.AddWithValue ("@id", CustomFeedId.toString feedId) ] |> ignore
|
||||||
|
let _ = cmd.ExecuteNonQuery ()
|
||||||
|
cmd.Parameters.Clear ())
|
||||||
|
cmd.CommandText <- "SELECT * FROM post_episode"
|
||||||
|
use! epRdr = cmd.ExecuteReaderAsync ()
|
||||||
|
let episodes =
|
||||||
|
seq {
|
||||||
|
while epRdr.Read () do
|
||||||
|
PostId (Map.getString "post_id" epRdr),
|
||||||
|
{ Media = Map.getString "media" epRdr
|
||||||
|
Length = Map.getLong "length" epRdr
|
||||||
|
Duration = Map.tryTimeSpan "duration" epRdr
|
||||||
|
|> Option.map Duration.FromTimeSpan
|
||||||
|
MediaType = Map.tryString "media_type" epRdr
|
||||||
|
ImageUrl = Map.tryString "image_url" epRdr
|
||||||
|
Subtitle = Map.tryString "subtitle" epRdr
|
||||||
|
Explicit = Map.tryString "explicit" epRdr
|
||||||
|
|> Option.map ExplicitRating.parse
|
||||||
|
ChapterFile = Map.tryString "chapter_file" epRdr
|
||||||
|
ChapterType = Map.tryString "chapter_type" epRdr
|
||||||
|
TranscriptUrl = Map.tryString "transcript_url" epRdr
|
||||||
|
TranscriptType = Map.tryString "transcript_type" epRdr
|
||||||
|
TranscriptLang = Map.tryString "transcript_lang" epRdr
|
||||||
|
TranscriptCaptions = Map.tryBoolean "transcript_captions" epRdr
|
||||||
|
SeasonNumber = Map.tryInt "season_number" epRdr
|
||||||
|
SeasonDescription = Map.tryString "season_description" epRdr
|
||||||
|
EpisodeNumber = Map.tryString "episode_number" epRdr
|
||||||
|
|> Option.map System.Double.Parse
|
||||||
|
EpisodeDescription = Map.tryString "episode_description" epRdr
|
||||||
|
}
|
||||||
|
} |> List.ofSeq
|
||||||
|
epRdr.Close ()
|
||||||
|
episodes
|
||||||
|
|> List.iter (fun (postId, episode) ->
|
||||||
|
cmd.CommandText <- "UPDATE post SET episode = @episode WHERE id = @id"
|
||||||
|
[ cmd.Parameters.AddWithValue ("@episode", Utils.serialize ser episode)
|
||||||
|
cmd.Parameters.AddWithValue ("@id", PostId.toString postId) ] |> ignore
|
||||||
|
let _ = cmd.ExecuteNonQuery ()
|
||||||
|
cmd.Parameters.Clear ())
|
||||||
|
|
||||||
|
logStep "Migrating dates/times"
|
||||||
|
let inst (dt : System.DateTime) =
|
||||||
|
System.DateTime (dt.Ticks, System.DateTimeKind.Utc)
|
||||||
|
|> (Instant.FromDateTimeUtc >> Noda.toSecondsPrecision)
|
||||||
|
// page.updated_on, page.published_on
|
||||||
|
cmd.CommandText <- "SELECT id, updated_on, published_on FROM page"
|
||||||
|
use! pageRdr = cmd.ExecuteReaderAsync ()
|
||||||
|
let toUpdate =
|
||||||
|
seq {
|
||||||
|
while pageRdr.Read () do
|
||||||
|
Map.getString "id" pageRdr,
|
||||||
|
inst (Map.getDateTime "updated_on" pageRdr),
|
||||||
|
inst (Map.getDateTime "published_on" pageRdr)
|
||||||
|
} |> List.ofSeq
|
||||||
|
pageRdr.Close ()
|
||||||
|
cmd.CommandText <- "UPDATE page SET updated_on = @updatedOn, published_on = @publishedOn WHERE id = @id"
|
||||||
|
[ cmd.Parameters.Add ("@id", SqliteType.Text)
|
||||||
|
cmd.Parameters.Add ("@updatedOn", SqliteType.Text)
|
||||||
|
cmd.Parameters.Add ("@publishedOn", SqliteType.Text)
|
||||||
|
] |> ignore
|
||||||
|
toUpdate
|
||||||
|
|> List.iter (fun (pageId, updatedOn, publishedOn) ->
|
||||||
|
cmd.Parameters["@id" ].Value <- pageId
|
||||||
|
cmd.Parameters["@updatedOn" ].Value <- instantParam updatedOn
|
||||||
|
cmd.Parameters["@publishedOn"].Value <- instantParam publishedOn
|
||||||
|
let _ = cmd.ExecuteNonQuery ()
|
||||||
|
())
|
||||||
|
cmd.Parameters.Clear ()
|
||||||
|
// page_revision.as_of
|
||||||
|
cmd.CommandText <- "SELECT * FROM page_revision"
|
||||||
|
use! pageRevRdr = cmd.ExecuteReaderAsync ()
|
||||||
|
let toUpdate =
|
||||||
|
seq {
|
||||||
|
while pageRevRdr.Read () do
|
||||||
|
let asOf = Map.getDateTime "as_of" pageRevRdr
|
||||||
|
Map.getString "page_id" pageRevRdr, asOf, inst asOf, Map.getString "revision_text" pageRevRdr
|
||||||
|
} |> List.ofSeq
|
||||||
|
pageRevRdr.Close ()
|
||||||
|
cmd.CommandText <-
|
||||||
|
"DELETE FROM page_revision WHERE page_id = @pageId AND as_of = @oldAsOf;
|
||||||
|
INSERT INTO page_revision (page_id, as_of, revision_text) VALUES (@pageId, @asOf, @text)"
|
||||||
|
[ cmd.Parameters.Add ("@pageId", SqliteType.Text)
|
||||||
|
cmd.Parameters.Add ("@oldAsOf", SqliteType.Text)
|
||||||
|
cmd.Parameters.Add ("@asOf", SqliteType.Text)
|
||||||
|
cmd.Parameters.Add ("@text", SqliteType.Text)
|
||||||
|
] |> ignore
|
||||||
|
toUpdate
|
||||||
|
|> List.iter (fun (pageId, oldAsOf, asOf, text) ->
|
||||||
|
cmd.Parameters["@pageId" ].Value <- pageId
|
||||||
|
cmd.Parameters["@oldAsOf"].Value <- oldAsOf
|
||||||
|
cmd.Parameters["@asOf" ].Value <- instantParam asOf
|
||||||
|
cmd.Parameters["@text" ].Value <- text
|
||||||
|
let _ = cmd.ExecuteNonQuery ()
|
||||||
|
())
|
||||||
|
cmd.Parameters.Clear ()
|
||||||
|
// post.updated_on, post.published_on (opt)
|
||||||
|
cmd.CommandText <- "SELECT id, updated_on, published_on FROM post"
|
||||||
|
use! postRdr = cmd.ExecuteReaderAsync ()
|
||||||
|
let toUpdate =
|
||||||
|
seq {
|
||||||
|
while postRdr.Read () do
|
||||||
|
Map.getString "id" postRdr,
|
||||||
|
inst (Map.getDateTime "updated_on" postRdr),
|
||||||
|
(Map.tryDateTime "published_on" postRdr |> Option.map inst)
|
||||||
|
} |> List.ofSeq
|
||||||
|
postRdr.Close ()
|
||||||
|
cmd.CommandText <- "UPDATE post SET updated_on = @updatedOn, published_on = @publishedOn WHERE id = @id"
|
||||||
|
[ cmd.Parameters.Add ("@id", SqliteType.Text)
|
||||||
|
cmd.Parameters.Add ("@updatedOn", SqliteType.Text)
|
||||||
|
cmd.Parameters.Add ("@publishedOn", SqliteType.Text)
|
||||||
|
] |> ignore
|
||||||
|
toUpdate
|
||||||
|
|> List.iter (fun (postId, updatedOn, publishedOn) ->
|
||||||
|
cmd.Parameters["@id" ].Value <- postId
|
||||||
|
cmd.Parameters["@updatedOn" ].Value <- instantParam updatedOn
|
||||||
|
cmd.Parameters["@publishedOn"].Value <- maybeInstant publishedOn
|
||||||
|
let _ = cmd.ExecuteNonQuery ()
|
||||||
|
())
|
||||||
|
cmd.Parameters.Clear ()
|
||||||
|
// post_revision.as_of
|
||||||
|
cmd.CommandText <- "SELECT * FROM post_revision"
|
||||||
|
use! postRevRdr = cmd.ExecuteReaderAsync ()
|
||||||
|
let toUpdate =
|
||||||
|
seq {
|
||||||
|
while postRevRdr.Read () do
|
||||||
|
let asOf = Map.getDateTime "as_of" postRevRdr
|
||||||
|
Map.getString "post_id" postRevRdr, asOf, inst asOf, Map.getString "revision_text" postRevRdr
|
||||||
|
} |> List.ofSeq
|
||||||
|
postRevRdr.Close ()
|
||||||
|
cmd.CommandText <-
|
||||||
|
"DELETE FROM post_revision WHERE post_id = @postId AND as_of = @oldAsOf;
|
||||||
|
INSERT INTO post_revision (post_id, as_of, revision_text) VALUES (@postId, @asOf, @text)"
|
||||||
|
[ cmd.Parameters.Add ("@postId", SqliteType.Text)
|
||||||
|
cmd.Parameters.Add ("@oldAsOf", SqliteType.Text)
|
||||||
|
cmd.Parameters.Add ("@asOf", SqliteType.Text)
|
||||||
|
cmd.Parameters.Add ("@text", SqliteType.Text)
|
||||||
|
] |> ignore
|
||||||
|
toUpdate
|
||||||
|
|> List.iter (fun (postId, oldAsOf, asOf, text) ->
|
||||||
|
cmd.Parameters["@postId" ].Value <- postId
|
||||||
|
cmd.Parameters["@oldAsOf"].Value <- oldAsOf
|
||||||
|
cmd.Parameters["@asOf" ].Value <- instantParam asOf
|
||||||
|
cmd.Parameters["@text" ].Value <- text
|
||||||
|
let _ = cmd.ExecuteNonQuery ()
|
||||||
|
())
|
||||||
|
cmd.Parameters.Clear ()
|
||||||
|
// theme_asset.updated_on
|
||||||
|
cmd.CommandText <- "SELECT theme_id, path, updated_on FROM theme_asset"
|
||||||
|
use! assetRdr = cmd.ExecuteReaderAsync ()
|
||||||
|
let toUpdate =
|
||||||
|
seq {
|
||||||
|
while assetRdr.Read () do
|
||||||
|
Map.getString "theme_id" assetRdr, Map.getString "path" assetRdr,
|
||||||
|
inst (Map.getDateTime "updated_on" assetRdr)
|
||||||
|
} |> List.ofSeq
|
||||||
|
assetRdr.Close ()
|
||||||
|
cmd.CommandText <- "UPDATE theme_asset SET updated_on = @updatedOn WHERE theme_id = @themeId AND path = @path"
|
||||||
|
[ cmd.Parameters.Add ("@updatedOn", SqliteType.Text)
|
||||||
|
cmd.Parameters.Add ("@themeId", SqliteType.Text)
|
||||||
|
cmd.Parameters.Add ("@path", SqliteType.Text)
|
||||||
|
] |> ignore
|
||||||
|
toUpdate
|
||||||
|
|> List.iter (fun (themeId, path, updatedOn) ->
|
||||||
|
cmd.Parameters["@themeId" ].Value <- themeId
|
||||||
|
cmd.Parameters["@path" ].Value <- path
|
||||||
|
cmd.Parameters["@updatedOn"].Value <- instantParam updatedOn
|
||||||
|
let _ = cmd.ExecuteNonQuery ()
|
||||||
|
())
|
||||||
|
cmd.Parameters.Clear ()
|
||||||
|
// upload.updated_on
|
||||||
|
cmd.CommandText <- "SELECT id, updated_on FROM upload"
|
||||||
|
use! upRdr = cmd.ExecuteReaderAsync ()
|
||||||
|
let toUpdate =
|
||||||
|
seq {
|
||||||
|
while upRdr.Read () do
|
||||||
|
Map.getString "id" upRdr, inst (Map.getDateTime "updated_on" upRdr)
|
||||||
|
} |> List.ofSeq
|
||||||
|
upRdr.Close ()
|
||||||
|
cmd.CommandText <- "UPDATE upload SET updated_on = @updatedOn WHERE id = @id"
|
||||||
|
[ cmd.Parameters.Add ("@updatedOn", SqliteType.Text)
|
||||||
|
cmd.Parameters.Add ("@id", SqliteType.Text)
|
||||||
|
] |> ignore
|
||||||
|
toUpdate
|
||||||
|
|> List.iter (fun (upId, updatedOn) ->
|
||||||
|
cmd.Parameters["@id" ].Value <- upId
|
||||||
|
cmd.Parameters["@updatedOn"].Value <- instantParam updatedOn
|
||||||
|
let _ = cmd.ExecuteNonQuery ()
|
||||||
|
())
|
||||||
|
cmd.Parameters.Clear ()
|
||||||
|
// web_log_user.created_on, web_log_user.last_seen_on (opt)
|
||||||
|
cmd.CommandText <- "SELECT id, created_on, last_seen_on FROM web_log_user"
|
||||||
|
use! userRdr = cmd.ExecuteReaderAsync ()
|
||||||
|
let toUpdate =
|
||||||
|
seq {
|
||||||
|
while userRdr.Read () do
|
||||||
|
Map.getString "id" userRdr,
|
||||||
|
inst (Map.getDateTime "created_on" userRdr),
|
||||||
|
(Map.tryDateTime "last_seen_on" userRdr |> Option.map inst)
|
||||||
|
} |> List.ofSeq
|
||||||
|
userRdr.Close ()
|
||||||
|
cmd.CommandText <- "UPDATE web_log_user SET created_on = @createdOn, last_seen_on = @lastSeenOn WHERE id = @id"
|
||||||
|
[ cmd.Parameters.Add ("@id", SqliteType.Text)
|
||||||
|
cmd.Parameters.Add ("@createdOn", SqliteType.Text)
|
||||||
|
cmd.Parameters.Add ("@lastSeenOn", SqliteType.Text)
|
||||||
|
] |> ignore
|
||||||
|
toUpdate
|
||||||
|
|> List.iter (fun (userId, createdOn, lastSeenOn) ->
|
||||||
|
cmd.Parameters["@id" ].Value <- userId
|
||||||
|
cmd.Parameters["@createdOn" ].Value <- instantParam createdOn
|
||||||
|
cmd.Parameters["@lastSeenOn"].Value <- maybeInstant lastSeenOn
|
||||||
|
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;
|
||||||
|
DROP TABLE post_episode;
|
||||||
|
DROP TABLE post_meta;
|
||||||
|
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 data among versions (up only)
|
||||||
|
let migrate version = backgroundTask {
|
||||||
|
|
||||||
|
match version with
|
||||||
|
| Some v when v = "v2-rc2" -> ()
|
||||||
|
| Some v when v = "v2-rc1" -> do! migrateV2Rc1ToV2Rc2 ()
|
||||||
|
| Some _
|
||||||
|
| None ->
|
||||||
|
log.LogWarning $"Unknown database version; assuming {Utils.currentDbVersion}"
|
||||||
|
do! setDbVersion Utils.currentDbVersion
|
||||||
|
}
|
||||||
|
|
||||||
/// The connection for this instance
|
/// The connection for this instance
|
||||||
member _.Conn = conn
|
member _.Conn = conn
|
||||||
|
|
||||||
@@ -31,355 +556,26 @@ type SQLiteData (conn : SqliteConnection, log : ILogger<SQLiteData>) =
|
|||||||
interface IData with
|
interface IData with
|
||||||
|
|
||||||
member _.Category = SQLiteCategoryData conn
|
member _.Category = SQLiteCategoryData conn
|
||||||
member _.Page = SQLitePageData conn
|
member _.Page = SQLitePageData (conn, ser)
|
||||||
member _.Post = SQLitePostData conn
|
member _.Post = SQLitePostData (conn, ser)
|
||||||
member _.TagMap = SQLiteTagMapData conn
|
member _.TagMap = SQLiteTagMapData conn
|
||||||
member _.Theme = SQLiteThemeData conn
|
member _.Theme = SQLiteThemeData conn
|
||||||
member _.ThemeAsset = SQLiteThemeAssetData conn
|
member _.ThemeAsset = SQLiteThemeAssetData conn
|
||||||
member _.Upload = SQLiteUploadData conn
|
member _.Upload = SQLiteUploadData conn
|
||||||
member _.WebLog = SQLiteWebLogData conn
|
member _.WebLog = SQLiteWebLogData (conn, ser)
|
||||||
member _.WebLogUser = SQLiteWebLogUserData conn
|
member _.WebLogUser = SQLiteWebLogUserData conn
|
||||||
|
|
||||||
|
member _.Serializer = ser
|
||||||
|
|
||||||
member _.StartUp () = backgroundTask {
|
member _.StartUp () = backgroundTask {
|
||||||
|
do! ensureTables ()
|
||||||
|
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
|
cmd.CommandText <- "SELECT id FROM db_version"
|
||||||
// Theme tables
|
use! rdr = cmd.ExecuteReaderAsync ()
|
||||||
match! tableExists "theme" with
|
let version = if rdr.Read () then Some (Map.getString "id" rdr) else None
|
||||||
| true -> ()
|
match version with
|
||||||
| false ->
|
| Some v when v = "v2-rc2" -> ()
|
||||||
log.LogInformation "Creating theme table..."
|
| Some _
|
||||||
cmd.CommandText <- """
|
| None -> do! migrate version
|
||||||
CREATE TABLE theme (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
version TEXT NOT NULL)"""
|
|
||||||
do! write cmd
|
|
||||||
match! tableExists "theme_template" with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
log.LogInformation "Creating theme_template table..."
|
|
||||||
cmd.CommandText <- """
|
|
||||||
CREATE TABLE theme_template (
|
|
||||||
theme_id TEXT NOT NULL REFERENCES theme (id),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
template TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (theme_id, name))"""
|
|
||||||
do! write cmd
|
|
||||||
match! tableExists "theme_asset" with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
log.LogInformation "Creating theme_asset table..."
|
|
||||||
cmd.CommandText <- """
|
|
||||||
CREATE TABLE theme_asset (
|
|
||||||
theme_id TEXT NOT NULL REFERENCES theme (id),
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
updated_on TEXT NOT NULL,
|
|
||||||
data BLOB NOT NULL,
|
|
||||||
PRIMARY KEY (theme_id, path))"""
|
|
||||||
do! write cmd
|
|
||||||
|
|
||||||
// Web log tables
|
|
||||||
match! tableExists "web_log" with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
log.LogInformation "Creating web_log table..."
|
|
||||||
cmd.CommandText <- """
|
|
||||||
CREATE TABLE web_log (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
slug TEXT NOT NULL,
|
|
||||||
subtitle TEXT,
|
|
||||||
default_page TEXT NOT NULL,
|
|
||||||
posts_per_page INTEGER NOT NULL,
|
|
||||||
theme_id TEXT NOT NULL REFERENCES theme (id),
|
|
||||||
url_base TEXT NOT NULL,
|
|
||||||
time_zone TEXT NOT NULL,
|
|
||||||
auto_htmx INTEGER NOT NULL DEFAULT 0,
|
|
||||||
uploads TEXT NOT NULL,
|
|
||||||
is_feed_enabled INTEGER NOT NULL DEFAULT 0,
|
|
||||||
feed_name TEXT NOT NULL,
|
|
||||||
items_in_feed INTEGER,
|
|
||||||
is_category_enabled INTEGER NOT NULL DEFAULT 0,
|
|
||||||
is_tag_enabled INTEGER NOT NULL DEFAULT 0,
|
|
||||||
copyright TEXT);
|
|
||||||
CREATE INDEX web_log_theme_idx ON web_log (theme_id)"""
|
|
||||||
do! write cmd
|
|
||||||
match! tableExists "web_log_feed" with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
log.LogInformation "Creating web_log_feed table..."
|
|
||||||
cmd.CommandText <- """
|
|
||||||
CREATE TABLE web_log_feed (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
web_log_id TEXT NOT NULL REFERENCES web_log (id),
|
|
||||||
source TEXT NOT NULL,
|
|
||||||
path TEXT NOT NULL);
|
|
||||||
CREATE INDEX web_log_feed_web_log_idx ON web_log_feed (web_log_id)"""
|
|
||||||
do! write cmd
|
|
||||||
match! tableExists "web_log_feed_podcast" with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
log.LogInformation "Creating web_log_feed_podcast table..."
|
|
||||||
cmd.CommandText <- """
|
|
||||||
CREATE TABLE web_log_feed_podcast (
|
|
||||||
feed_id TEXT PRIMARY KEY REFERENCES web_log_feed (id),
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
subtitle TEXT,
|
|
||||||
items_in_feed INTEGER NOT NULL,
|
|
||||||
summary TEXT NOT NULL,
|
|
||||||
displayed_author TEXT NOT NULL,
|
|
||||||
email TEXT NOT NULL,
|
|
||||||
image_url TEXT NOT NULL,
|
|
||||||
apple_category TEXT NOT NULL,
|
|
||||||
apple_subcategory TEXT,
|
|
||||||
explicit TEXT NOT NULL,
|
|
||||||
default_media_type TEXT,
|
|
||||||
media_base_url TEXT,
|
|
||||||
podcast_guid TEXT,
|
|
||||||
funding_url TEXT,
|
|
||||||
funding_text TEXT,
|
|
||||||
medium TEXT)"""
|
|
||||||
do! write cmd
|
|
||||||
|
|
||||||
// Category table
|
|
||||||
match! tableExists "category" with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
log.LogInformation "Creating category table..."
|
|
||||||
cmd.CommandText <- """
|
|
||||||
CREATE TABLE category (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
web_log_id TEXT NOT NULL REFERENCES web_log (id),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
slug TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
parent_id TEXT);
|
|
||||||
CREATE INDEX category_web_log_idx ON category (web_log_id)"""
|
|
||||||
do! write cmd
|
|
||||||
|
|
||||||
// Web log user table
|
|
||||||
match! tableExists "web_log_user" with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
log.LogInformation "Creating web_log_user table..."
|
|
||||||
cmd.CommandText <- """
|
|
||||||
CREATE TABLE web_log_user (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
web_log_id TEXT NOT NULL REFERENCES web_log (id),
|
|
||||||
email TEXT NOT NULL,
|
|
||||||
first_name TEXT NOT NULL,
|
|
||||||
last_name TEXT NOT NULL,
|
|
||||||
preferred_name TEXT NOT NULL,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
salt TEXT NOT NULL,
|
|
||||||
url TEXT,
|
|
||||||
access_level TEXT NOT NULL,
|
|
||||||
created_on TEXT NOT NULL,
|
|
||||||
last_seen_on TEXT);
|
|
||||||
CREATE INDEX web_log_user_web_log_idx ON web_log_user (web_log_id);
|
|
||||||
CREATE INDEX web_log_user_email_idx ON web_log_user (web_log_id, email)"""
|
|
||||||
do! write cmd
|
|
||||||
|
|
||||||
// Page tables
|
|
||||||
match! tableExists "page" with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
log.LogInformation "Creating page table..."
|
|
||||||
cmd.CommandText <- """
|
|
||||||
CREATE TABLE page (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
web_log_id TEXT NOT NULL REFERENCES web_log (id),
|
|
||||||
author_id TEXT NOT NULL REFERENCES web_log_user (id),
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
permalink TEXT NOT NULL,
|
|
||||||
published_on TEXT NOT NULL,
|
|
||||||
updated_on TEXT NOT NULL,
|
|
||||||
is_in_page_list INTEGER NOT NULL DEFAULT 0,
|
|
||||||
template TEXT,
|
|
||||||
page_text TEXT NOT NULL);
|
|
||||||
CREATE INDEX page_web_log_idx ON page (web_log_id);
|
|
||||||
CREATE INDEX page_author_idx ON page (author_id);
|
|
||||||
CREATE INDEX page_permalink_idx ON page (web_log_id, permalink)"""
|
|
||||||
do! write cmd
|
|
||||||
match! tableExists "page_meta" with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
log.LogInformation "Creating page_meta table..."
|
|
||||||
cmd.CommandText <- """
|
|
||||||
CREATE TABLE page_meta (
|
|
||||||
page_id TEXT NOT NULL REFERENCES page (id),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (page_id, name, value))"""
|
|
||||||
do! write cmd
|
|
||||||
match! tableExists "page_permalink" with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
log.LogInformation "Creating page_permalink table..."
|
|
||||||
cmd.CommandText <- """
|
|
||||||
CREATE TABLE page_permalink (
|
|
||||||
page_id TEXT NOT NULL REFERENCES page (id),
|
|
||||||
permalink TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (page_id, permalink))"""
|
|
||||||
do! write cmd
|
|
||||||
match! tableExists "page_revision" with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
log.LogInformation "Creating page_revision table..."
|
|
||||||
cmd.CommandText <- """
|
|
||||||
CREATE TABLE page_revision (
|
|
||||||
page_id TEXT NOT NULL REFERENCES page (id),
|
|
||||||
as_of TEXT NOT NULL,
|
|
||||||
revision_text TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (page_id, as_of))"""
|
|
||||||
do! write cmd
|
|
||||||
|
|
||||||
// Post tables
|
|
||||||
match! tableExists "post" with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
log.LogInformation "Creating post table..."
|
|
||||||
cmd.CommandText <- """
|
|
||||||
CREATE TABLE post (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
web_log_id TEXT NOT NULL REFERENCES web_log (id),
|
|
||||||
author_id TEXT NOT NULL REFERENCES web_log_user (id),
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
permalink TEXT NOT NULL,
|
|
||||||
published_on TEXT,
|
|
||||||
updated_on TEXT NOT NULL,
|
|
||||||
template TEXT,
|
|
||||||
post_text TEXT NOT NULL);
|
|
||||||
CREATE INDEX post_web_log_idx ON post (web_log_id);
|
|
||||||
CREATE INDEX post_author_idx ON post (author_id);
|
|
||||||
CREATE INDEX post_status_idx ON post (web_log_id, status, updated_on);
|
|
||||||
CREATE INDEX post_permalink_idx ON post (web_log_id, permalink)"""
|
|
||||||
do! write cmd
|
|
||||||
match! tableExists "post_category" with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
log.LogInformation "Creating post_category table..."
|
|
||||||
cmd.CommandText <- """
|
|
||||||
CREATE TABLE post_category (
|
|
||||||
post_id TEXT NOT NULL REFERENCES post (id),
|
|
||||||
category_id TEXT NOT NULL REFERENCES category (id),
|
|
||||||
PRIMARY KEY (post_id, category_id));
|
|
||||||
CREATE INDEX post_category_category_idx ON post_category (category_id)"""
|
|
||||||
do! write cmd
|
|
||||||
match! tableExists "post_episode" with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
log.LogInformation "Creating post_episode table..."
|
|
||||||
cmd.CommandText <- """
|
|
||||||
CREATE TABLE post_episode (
|
|
||||||
post_id TEXT PRIMARY KEY REFERENCES post(id),
|
|
||||||
media TEXT NOT NULL,
|
|
||||||
length INTEGER NOT NULL,
|
|
||||||
duration TEXT,
|
|
||||||
media_type TEXT,
|
|
||||||
image_url TEXT,
|
|
||||||
subtitle TEXT,
|
|
||||||
explicit TEXT,
|
|
||||||
chapter_file TEXT,
|
|
||||||
chapter_type TEXT,
|
|
||||||
transcript_url TEXT,
|
|
||||||
transcript_type TEXT,
|
|
||||||
transcript_lang TEXT,
|
|
||||||
transcript_captions INTEGER,
|
|
||||||
season_number INTEGER,
|
|
||||||
season_description TEXT,
|
|
||||||
episode_number TEXT,
|
|
||||||
episode_description TEXT)"""
|
|
||||||
do! write cmd
|
|
||||||
match! tableExists "post_tag" with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
log.LogInformation "Creating post_tag table..."
|
|
||||||
cmd.CommandText <- """
|
|
||||||
CREATE TABLE post_tag (
|
|
||||||
post_id TEXT NOT NULL REFERENCES post (id),
|
|
||||||
tag TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (post_id, tag))"""
|
|
||||||
do! write cmd
|
|
||||||
match! tableExists "post_meta" with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
log.LogInformation "Creating post_meta table..."
|
|
||||||
cmd.CommandText <- """
|
|
||||||
CREATE TABLE post_meta (
|
|
||||||
post_id TEXT NOT NULL REFERENCES post (id),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (post_id, name, value))"""
|
|
||||||
do! write cmd
|
|
||||||
match! tableExists "post_permalink" with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
log.LogInformation "Creating post_permalink table..."
|
|
||||||
cmd.CommandText <- """
|
|
||||||
CREATE TABLE post_permalink (
|
|
||||||
post_id TEXT NOT NULL REFERENCES post (id),
|
|
||||||
permalink TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (post_id, permalink))"""
|
|
||||||
do! write cmd
|
|
||||||
match! tableExists "post_revision" with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
log.LogInformation "Creating post_revision table..."
|
|
||||||
cmd.CommandText <- """
|
|
||||||
CREATE TABLE post_revision (
|
|
||||||
post_id TEXT NOT NULL REFERENCES post (id),
|
|
||||||
as_of TEXT NOT NULL,
|
|
||||||
revision_text TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (post_id, as_of))"""
|
|
||||||
do! write cmd
|
|
||||||
match! tableExists "post_comment" with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
log.LogInformation "Creating post_comment table..."
|
|
||||||
cmd.CommandText <- """
|
|
||||||
CREATE TABLE post_comment (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
post_id TEXT NOT NULL REFERENCES post(id),
|
|
||||||
in_reply_to_id TEXT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
email TEXT NOT NULL,
|
|
||||||
url TEXT,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
posted_on TEXT NOT NULL,
|
|
||||||
comment_text TEXT NOT NULL);
|
|
||||||
CREATE INDEX post_comment_post_idx ON post_comment (post_id)"""
|
|
||||||
do! write cmd
|
|
||||||
|
|
||||||
// Tag map table
|
|
||||||
match! tableExists "tag_map" with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
log.LogInformation "Creating tag_map table..."
|
|
||||||
cmd.CommandText <- """
|
|
||||||
CREATE TABLE tag_map (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
web_log_id TEXT NOT NULL REFERENCES web_log (id),
|
|
||||||
tag TEXT NOT NULL,
|
|
||||||
url_value TEXT NOT NULL);
|
|
||||||
CREATE INDEX tag_map_web_log_idx ON tag_map (web_log_id)"""
|
|
||||||
do! write cmd
|
|
||||||
|
|
||||||
// Uploaded file table
|
|
||||||
match! tableExists "upload" with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
log.LogInformation "Creating upload table..."
|
|
||||||
cmd.CommandText <- """
|
|
||||||
CREATE TABLE upload (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
web_log_id TEXT NOT NULL REFERENCES web_log (id),
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
updated_on TEXT NOT NULL,
|
|
||||||
data BLOB NOT NULL);
|
|
||||||
CREATE INDEX upload_web_log_idx ON upload (web_log_id);
|
|
||||||
CREATE INDEX upload_path_idx ON upload (web_log_id, path)"""
|
|
||||||
do! write cmd
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ module internal MyWebLog.Data.Utils
|
|||||||
open MyWebLog
|
open MyWebLog
|
||||||
open MyWebLog.ViewModels
|
open MyWebLog.ViewModels
|
||||||
|
|
||||||
|
/// The current database version
|
||||||
|
let currentDbVersion = "v2-rc2"
|
||||||
|
|
||||||
/// Create a category hierarchy from the given list of categories
|
/// Create a category hierarchy from the given list of categories
|
||||||
let rec orderByHierarchy (cats : Category list) parentId slugBase parentNames = seq {
|
let rec orderByHierarchy (cats : Category list) parentId slugBase parentNames = seq {
|
||||||
for cat in cats |> List.filter (fun c -> c.ParentId = parentId) do
|
for cat in cats |> List.filter (fun c -> c.ParentId = parentId) do
|
||||||
@@ -20,3 +23,36 @@ let rec orderByHierarchy (cats : Category list) parentId slugBase parentNames =
|
|||||||
yield! orderByHierarchy cats (Some cat.Id) (Some fullSlug) ([ cat.Name ] |> List.append parentNames)
|
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
|
||||||
|
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 meta items added and removed
|
||||||
|
let diffMetaItems (oldItems : MetaItem list) newItems =
|
||||||
|
diffLists oldItems newItems (fun item -> $"{item.Name}|{item.Value}")
|
||||||
|
|
||||||
|
/// Find the permalinks added and removed
|
||||||
|
let diffPermalinks oldLinks newLinks =
|
||||||
|
diffLists oldLinks newLinks Permalink.toString
|
||||||
|
|
||||||
|
/// Find the revisions added and removed
|
||||||
|
let diffRevisions oldRevs newRevs =
|
||||||
|
diffLists oldRevs newRevs (fun (rev : Revision) -> $"{rev.AsOf.ToUnixTimeTicks ()}|{MarkupText.toString rev.Text}")
|
||||||
|
|
||||||
|
open MyWebLog.Converters
|
||||||
|
open Newtonsoft.Json
|
||||||
|
|
||||||
|
/// Serialize an object to JSON
|
||||||
|
let serialize<'T> ser (item : 'T) =
|
||||||
|
JsonConvert.SerializeObject (item, Json.settings ser)
|
||||||
|
|
||||||
|
/// Deserialize a JSON string
|
||||||
|
let deserialize<'T> (ser : JsonSerializer) value =
|
||||||
|
JsonConvert.DeserializeObject<'T> (value, Json.settings ser)
|
||||||
|
|
||||||
|
open Microsoft.Extensions.Logging
|
||||||
|
|
||||||
|
/// Log a migration step
|
||||||
|
let logMigrationStep<'T> (log : ILogger<'T>) migration message =
|
||||||
|
log.LogInformation $"Migrating %s{migration}: %s{message}"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
open System
|
open System
|
||||||
open MyWebLog
|
open MyWebLog
|
||||||
|
open NodaTime
|
||||||
|
|
||||||
/// A category under which a post may be identified
|
/// A category under which a post may be identified
|
||||||
[<CLIMutable; NoComparison; NoEquality>]
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
@@ -64,7 +65,7 @@ type Comment =
|
|||||||
Status : CommentStatus
|
Status : CommentStatus
|
||||||
|
|
||||||
/// When the comment was posted
|
/// When the comment was posted
|
||||||
PostedOn : DateTime
|
PostedOn : Instant
|
||||||
|
|
||||||
/// The text of the comment
|
/// The text of the comment
|
||||||
Text : string
|
Text : string
|
||||||
@@ -82,7 +83,7 @@ module Comment =
|
|||||||
Email = ""
|
Email = ""
|
||||||
Url = None
|
Url = None
|
||||||
Status = Pending
|
Status = Pending
|
||||||
PostedOn = DateTime.UtcNow
|
PostedOn = Noda.epoch
|
||||||
Text = ""
|
Text = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,10 +107,10 @@ type Page =
|
|||||||
Permalink : Permalink
|
Permalink : Permalink
|
||||||
|
|
||||||
/// When this page was published
|
/// When this page was published
|
||||||
PublishedOn : DateTime
|
PublishedOn : Instant
|
||||||
|
|
||||||
/// When this page was last updated
|
/// When this page was last updated
|
||||||
UpdatedOn : DateTime
|
UpdatedOn : Instant
|
||||||
|
|
||||||
/// Whether this page shows as part of the web log's navigation
|
/// Whether this page shows as part of the web log's navigation
|
||||||
IsInPageList : bool
|
IsInPageList : bool
|
||||||
@@ -140,8 +141,8 @@ module Page =
|
|||||||
AuthorId = WebLogUserId.empty
|
AuthorId = WebLogUserId.empty
|
||||||
Title = ""
|
Title = ""
|
||||||
Permalink = Permalink.empty
|
Permalink = Permalink.empty
|
||||||
PublishedOn = DateTime.MinValue
|
PublishedOn = Noda.epoch
|
||||||
UpdatedOn = DateTime.MinValue
|
UpdatedOn = Noda.epoch
|
||||||
IsInPageList = false
|
IsInPageList = false
|
||||||
Template = None
|
Template = None
|
||||||
Text = ""
|
Text = ""
|
||||||
@@ -173,10 +174,10 @@ type Post =
|
|||||||
Permalink : Permalink
|
Permalink : Permalink
|
||||||
|
|
||||||
/// The instant on which the post was originally published
|
/// The instant on which the post was originally published
|
||||||
PublishedOn : DateTime option
|
PublishedOn : Instant option
|
||||||
|
|
||||||
/// The instant on which the post was last updated
|
/// The instant on which the post was last updated
|
||||||
UpdatedOn : DateTime
|
UpdatedOn : Instant
|
||||||
|
|
||||||
/// The template to use in displaying the post
|
/// The template to use in displaying the post
|
||||||
Template : string option
|
Template : string option
|
||||||
@@ -215,7 +216,7 @@ module Post =
|
|||||||
Title = ""
|
Title = ""
|
||||||
Permalink = Permalink.empty
|
Permalink = Permalink.empty
|
||||||
PublishedOn = None
|
PublishedOn = None
|
||||||
UpdatedOn = DateTime.MinValue
|
UpdatedOn = Noda.epoch
|
||||||
Text = ""
|
Text = ""
|
||||||
Template = None
|
Template = None
|
||||||
CategoryIds = []
|
CategoryIds = []
|
||||||
@@ -288,7 +289,7 @@ type ThemeAsset =
|
|||||||
Id : ThemeAssetId
|
Id : ThemeAssetId
|
||||||
|
|
||||||
/// The updated date (set from the file date from the ZIP archive)
|
/// The updated date (set from the file date from the ZIP archive)
|
||||||
UpdatedOn : DateTime
|
UpdatedOn : Instant
|
||||||
|
|
||||||
/// The data for the asset
|
/// The data for the asset
|
||||||
Data : byte[]
|
Data : byte[]
|
||||||
@@ -300,7 +301,7 @@ module ThemeAsset =
|
|||||||
/// An empty theme asset
|
/// An empty theme asset
|
||||||
let empty =
|
let empty =
|
||||||
{ Id = ThemeAssetId (ThemeId "", "")
|
{ Id = ThemeAssetId (ThemeId "", "")
|
||||||
UpdatedOn = DateTime.MinValue
|
UpdatedOn = Noda.epoch
|
||||||
Data = [||]
|
Data = [||]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +318,7 @@ type Upload =
|
|||||||
Path : Permalink
|
Path : Permalink
|
||||||
|
|
||||||
/// The updated date/time for this upload
|
/// The updated date/time for this upload
|
||||||
UpdatedOn : DateTime
|
UpdatedOn : Instant
|
||||||
|
|
||||||
/// The data for the upload
|
/// The data for the upload
|
||||||
Data : byte[]
|
Data : byte[]
|
||||||
@@ -331,7 +332,7 @@ module Upload =
|
|||||||
{ Id = UploadId.empty
|
{ Id = UploadId.empty
|
||||||
WebLogId = WebLogId.empty
|
WebLogId = WebLogId.empty
|
||||||
Path = Permalink.empty
|
Path = Permalink.empty
|
||||||
UpdatedOn = DateTime.MinValue
|
UpdatedOn = Noda.epoch
|
||||||
Data = [||]
|
Data = [||]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,10 +411,11 @@ module WebLog =
|
|||||||
let _, leadPath = hostAndPath webLog
|
let _, leadPath = hostAndPath webLog
|
||||||
$"{leadPath}/{Permalink.toString permalink}"
|
$"{leadPath}/{Permalink.toString permalink}"
|
||||||
|
|
||||||
/// Convert a UTC date/time to the web log's local date/time
|
/// Convert an Instant (UTC reference) to the web log's local date/time
|
||||||
let localTime webLog (date : DateTime) =
|
let localTime webLog (date : Instant) =
|
||||||
TimeZoneInfo.ConvertTimeFromUtc
|
match DateTimeZoneProviders.Tzdb[webLog.TimeZone] with
|
||||||
(DateTime (date.Ticks, DateTimeKind.Utc), TimeZoneInfo.FindSystemTimeZoneById webLog.TimeZone)
|
| null -> date.ToDateTimeUtc ()
|
||||||
|
| tz -> date.InZone(tz).ToDateTimeUnspecified ()
|
||||||
|
|
||||||
|
|
||||||
/// A user of the web log
|
/// A user of the web log
|
||||||
@@ -440,9 +442,6 @@ type WebLogUser =
|
|||||||
/// The hash of the user's password
|
/// The hash of the user's password
|
||||||
PasswordHash : string
|
PasswordHash : string
|
||||||
|
|
||||||
/// Salt used to calculate the user's password hash
|
|
||||||
Salt : Guid
|
|
||||||
|
|
||||||
/// The URL of the user's personal site
|
/// The URL of the user's personal site
|
||||||
Url : string option
|
Url : string option
|
||||||
|
|
||||||
@@ -450,10 +449,10 @@ type WebLogUser =
|
|||||||
AccessLevel : AccessLevel
|
AccessLevel : AccessLevel
|
||||||
|
|
||||||
/// When the user was created
|
/// When the user was created
|
||||||
CreatedOn : DateTime
|
CreatedOn : Instant
|
||||||
|
|
||||||
/// When the user last logged on
|
/// When the user last logged on
|
||||||
LastSeenOn : DateTime option
|
LastSeenOn : Instant option
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Functions to support web log users
|
/// Functions to support web log users
|
||||||
@@ -468,10 +467,9 @@ module WebLogUser =
|
|||||||
LastName = ""
|
LastName = ""
|
||||||
PreferredName = ""
|
PreferredName = ""
|
||||||
PasswordHash = ""
|
PasswordHash = ""
|
||||||
Salt = Guid.Empty
|
|
||||||
Url = None
|
Url = None
|
||||||
AccessLevel = Author
|
AccessLevel = Author
|
||||||
CreatedOn = DateTime.UnixEpoch
|
CreatedOn = Noda.epoch
|
||||||
LastSeenOn = None
|
LastSeenOn = None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
|
||||||
<DebugType>embedded</DebugType>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="SupportTypes.fs" />
|
<Compile Include="SupportTypes.fs" />
|
||||||
<Compile Include="DataTypes.fs" />
|
<Compile Include="DataTypes.fs" />
|
||||||
@@ -13,9 +7,10 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Markdig" Version="0.30.2" />
|
<PackageReference Include="Markdig" Version="0.30.3" />
|
||||||
<PackageReference Update="FSharp.Core" Version="6.0.5" />
|
<PackageReference Update="FSharp.Core" Version="6.0.5" />
|
||||||
<PackageReference Include="Markdown.ColorCode" Version="1.0.1" />
|
<PackageReference Include="Markdown.ColorCode" Version="1.0.1" />
|
||||||
|
<PackageReference Include="NodaTime" Version="3.1.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
namespace MyWebLog
|
namespace MyWebLog
|
||||||
|
|
||||||
open System
|
open System
|
||||||
|
open NodaTime
|
||||||
|
|
||||||
/// Support functions for domain definition
|
/// Support functions for domain definition
|
||||||
[<AutoOpen>]
|
[<AutoOpen>]
|
||||||
@@ -12,6 +13,29 @@ module private Helpers =
|
|||||||
Convert.ToBase64String(Guid.NewGuid().ToByteArray ()).Replace('/', '_').Replace('+', '-').Substring (0, 22)
|
Convert.ToBase64String(Guid.NewGuid().ToByteArray ()).Replace('/', '_').Replace('+', '-').Substring (0, 22)
|
||||||
|
|
||||||
|
|
||||||
|
/// Functions to support NodaTime manipulation
|
||||||
|
module Noda =
|
||||||
|
|
||||||
|
/// The clock to use when getting "now" (will make mutable for testing)
|
||||||
|
let clock : IClock = SystemClock.Instance
|
||||||
|
|
||||||
|
/// The Unix epoch
|
||||||
|
let epoch = Instant.FromUnixTimeSeconds 0L
|
||||||
|
|
||||||
|
|
||||||
|
/// Truncate an instant to remove fractional seconds
|
||||||
|
let toSecondsPrecision (value : Instant) =
|
||||||
|
Instant.FromUnixTimeSeconds (value.ToUnixTimeSeconds ())
|
||||||
|
|
||||||
|
/// The current Instant, with fractional seconds truncated
|
||||||
|
let now () =
|
||||||
|
toSecondsPrecision (clock.GetCurrentInstant ())
|
||||||
|
|
||||||
|
/// Convert a date/time to an Instant with whole seconds
|
||||||
|
let fromDateTime (dt : DateTime) =
|
||||||
|
toSecondsPrecision (Instant.FromDateTimeUtc (DateTime (dt.Ticks, DateTimeKind.Utc)))
|
||||||
|
|
||||||
|
|
||||||
/// A user's access level
|
/// A user's access level
|
||||||
type AccessLevel =
|
type AccessLevel =
|
||||||
/// The user may create and publish posts and edit the ones they have created
|
/// The user may create and publish posts and edit the ones they have created
|
||||||
@@ -137,6 +161,8 @@ module ExplicitRating =
|
|||||||
| x -> raise (invalidArg "rating" $"{x} is not a valid explicit rating")
|
| x -> raise (invalidArg "rating" $"{x} is not a valid explicit rating")
|
||||||
|
|
||||||
|
|
||||||
|
open NodaTime.Text
|
||||||
|
|
||||||
/// A podcast episode
|
/// A podcast episode
|
||||||
type Episode =
|
type Episode =
|
||||||
{ /// The URL to the media file for the episode (may be permalink)
|
{ /// The URL to the media file for the episode (may be permalink)
|
||||||
@@ -146,7 +172,7 @@ type Episode =
|
|||||||
Length : int64
|
Length : int64
|
||||||
|
|
||||||
/// The duration of the episode
|
/// The duration of the episode
|
||||||
Duration : TimeSpan option
|
Duration : Duration option
|
||||||
|
|
||||||
/// The media type of the file (overrides podcast default if present)
|
/// The media type of the file (overrides podcast default if present)
|
||||||
MediaType : string option
|
MediaType : string option
|
||||||
@@ -214,6 +240,10 @@ module Episode =
|
|||||||
EpisodeNumber = None
|
EpisodeNumber = None
|
||||||
EpisodeDescription = None
|
EpisodeDescription = None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Format a duration for an episode
|
||||||
|
let formatDuration ep =
|
||||||
|
ep.Duration |> Option.map (DurationPattern.CreateWithInvariantCulture("H:mm:ss").Format)
|
||||||
|
|
||||||
|
|
||||||
open Markdig
|
open Markdig
|
||||||
@@ -269,12 +299,11 @@ module MetaItem =
|
|||||||
let empty =
|
let empty =
|
||||||
{ Name = ""; Value = "" }
|
{ Name = ""; Value = "" }
|
||||||
|
|
||||||
|
|
||||||
/// A revision of a page or post
|
/// A revision of a page or post
|
||||||
[<CLIMutable; NoComparison; NoEquality>]
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
type Revision =
|
type Revision =
|
||||||
{ /// When this revision was saved
|
{ /// When this revision was saved
|
||||||
AsOf : DateTime
|
AsOf : Instant
|
||||||
|
|
||||||
/// The text of the revision
|
/// The text of the revision
|
||||||
Text : MarkupText
|
Text : MarkupText
|
||||||
@@ -285,7 +314,7 @@ module Revision =
|
|||||||
|
|
||||||
/// An empty revision
|
/// An empty revision
|
||||||
let empty =
|
let empty =
|
||||||
{ AsOf = DateTime.UtcNow
|
{ AsOf = Noda.epoch
|
||||||
Text = Html ""
|
Text = Html ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
open System
|
open System
|
||||||
open MyWebLog
|
open MyWebLog
|
||||||
|
open NodaTime
|
||||||
|
|
||||||
/// Helper functions for view models
|
/// Helper functions for view models
|
||||||
[<AutoOpen>]
|
[<AutoOpen>]
|
||||||
@@ -12,6 +13,17 @@ module private Helpers =
|
|||||||
match (defaultArg (Option.ofObj it) "").Trim () with "" -> None | trimmed -> Some trimmed
|
match (defaultArg (Option.ofObj it) "").Trim () with "" -> None | trimmed -> Some trimmed
|
||||||
|
|
||||||
|
|
||||||
|
/// Helper functions that are needed outside this file
|
||||||
|
[<AutoOpen>]
|
||||||
|
module PublicHelpers =
|
||||||
|
|
||||||
|
/// If the web log is not being served from the domain root, add the path information to relative URLs in page and
|
||||||
|
/// post text
|
||||||
|
let addBaseToRelativeUrls extra (text : string) =
|
||||||
|
if extra = "" then text
|
||||||
|
else text.Replace("href=\"/", $"href=\"{extra}/").Replace ("src=\"/", $"src=\"{extra}/")
|
||||||
|
|
||||||
|
|
||||||
/// The model used to display the admin dashboard
|
/// The model used to display the admin dashboard
|
||||||
[<NoComparison; NoEquality>]
|
[<NoComparison; NoEquality>]
|
||||||
type DashboardModel =
|
type DashboardModel =
|
||||||
@@ -127,8 +139,8 @@ type DisplayPage =
|
|||||||
AuthorId = WebLogUserId.toString page.AuthorId
|
AuthorId = WebLogUserId.toString page.AuthorId
|
||||||
Title = page.Title
|
Title = page.Title
|
||||||
Permalink = Permalink.toString page.Permalink
|
Permalink = Permalink.toString page.Permalink
|
||||||
PublishedOn = page.PublishedOn
|
PublishedOn = WebLog.localTime webLog page.PublishedOn
|
||||||
UpdatedOn = page.UpdatedOn
|
UpdatedOn = WebLog.localTime webLog page.UpdatedOn
|
||||||
IsInPageList = page.IsInPageList
|
IsInPageList = page.IsInPageList
|
||||||
IsDefault = pageId = webLog.DefaultPage
|
IsDefault = pageId = webLog.DefaultPage
|
||||||
Text = ""
|
Text = ""
|
||||||
@@ -143,11 +155,11 @@ type DisplayPage =
|
|||||||
AuthorId = WebLogUserId.toString page.AuthorId
|
AuthorId = WebLogUserId.toString page.AuthorId
|
||||||
Title = page.Title
|
Title = page.Title
|
||||||
Permalink = Permalink.toString page.Permalink
|
Permalink = Permalink.toString page.Permalink
|
||||||
PublishedOn = page.PublishedOn
|
PublishedOn = WebLog.localTime webLog page.PublishedOn
|
||||||
UpdatedOn = page.UpdatedOn
|
UpdatedOn = WebLog.localTime webLog page.UpdatedOn
|
||||||
IsInPageList = page.IsInPageList
|
IsInPageList = page.IsInPageList
|
||||||
IsDefault = pageId = webLog.DefaultPage
|
IsDefault = pageId = webLog.DefaultPage
|
||||||
Text = if extra = "" then page.Text else page.Text.Replace ("href=\"/", $"href=\"{extra}/")
|
Text = addBaseToRelativeUrls extra page.Text
|
||||||
Metadata = page.Metadata
|
Metadata = page.Metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +180,7 @@ with
|
|||||||
|
|
||||||
/// Create a display revision from an actual revision
|
/// Create a display revision from an actual revision
|
||||||
static member fromRevision webLog (rev : Revision) =
|
static member fromRevision webLog (rev : Revision) =
|
||||||
{ AsOf = rev.AsOf
|
{ AsOf = rev.AsOf.ToDateTimeUtc ()
|
||||||
AsOfLocal = WebLog.localTime webLog rev.AsOf
|
AsOfLocal = WebLog.localTime webLog rev.AsOf
|
||||||
Format = MarkupText.sourceType rev.Text
|
Format = MarkupText.sourceType rev.Text
|
||||||
}
|
}
|
||||||
@@ -176,6 +188,40 @@ with
|
|||||||
|
|
||||||
open System.IO
|
open System.IO
|
||||||
|
|
||||||
|
/// Information about a theme used for display
|
||||||
|
[<NoComparison; NoEquality>]
|
||||||
|
type DisplayTheme =
|
||||||
|
{ /// The ID / path slug of the theme
|
||||||
|
Id : string
|
||||||
|
|
||||||
|
/// The name of the theme
|
||||||
|
Name : string
|
||||||
|
|
||||||
|
/// The version of the theme
|
||||||
|
Version : string
|
||||||
|
|
||||||
|
/// How many templates are contained in the theme
|
||||||
|
TemplateCount : int
|
||||||
|
|
||||||
|
/// Whether the theme is in use by any web logs
|
||||||
|
IsInUse : bool
|
||||||
|
|
||||||
|
/// Whether the theme .zip file exists on the filesystem
|
||||||
|
IsOnDisk : bool
|
||||||
|
}
|
||||||
|
with
|
||||||
|
|
||||||
|
/// Create a display theme from a theme
|
||||||
|
static member fromTheme inUseFunc (theme : Theme) =
|
||||||
|
{ Id = ThemeId.toString theme.Id
|
||||||
|
Name = theme.Name
|
||||||
|
Version = theme.Version
|
||||||
|
TemplateCount = List.length theme.Templates
|
||||||
|
IsInUse = inUseFunc theme.Id
|
||||||
|
IsOnDisk = File.Exists $"{ThemeId.toString theme.Id}-theme.zip"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Information about an uploaded file used for display
|
/// Information about an uploaded file used for display
|
||||||
[<NoComparison; NoEquality>]
|
[<NoComparison; NoEquality>]
|
||||||
type DisplayUpload =
|
type DisplayUpload =
|
||||||
@@ -658,7 +704,7 @@ type EditPostModel =
|
|||||||
match post.Revisions |> List.sortByDescending (fun r -> r.AsOf) |> List.tryHead with
|
match post.Revisions |> List.sortByDescending (fun r -> r.AsOf) |> List.tryHead with
|
||||||
| Some rev -> rev
|
| Some rev -> rev
|
||||||
| None -> Revision.empty
|
| None -> Revision.empty
|
||||||
let post = if post.Metadata |> List.isEmpty then { post with Metadata = [ MetaItem.empty ] } else post
|
let post = if post.Metadata |> List.isEmpty then { post with Metadata = [ MetaItem.empty ] } else post
|
||||||
let episode = defaultArg post.Episode Episode.empty
|
let episode = defaultArg post.Episode Episode.empty
|
||||||
{ PostId = PostId.toString post.Id
|
{ PostId = PostId.toString post.Id
|
||||||
Title = post.Title
|
Title = post.Title
|
||||||
@@ -678,7 +724,7 @@ type EditPostModel =
|
|||||||
IsEpisode = Option.isSome post.Episode
|
IsEpisode = Option.isSome post.Episode
|
||||||
Media = episode.Media
|
Media = episode.Media
|
||||||
Length = episode.Length
|
Length = episode.Length
|
||||||
Duration = defaultArg (episode.Duration |> Option.map (fun it -> it.ToString """hh\:mm\:ss""")) ""
|
Duration = defaultArg (Episode.formatDuration episode) ""
|
||||||
MediaType = defaultArg episode.MediaType ""
|
MediaType = defaultArg episode.MediaType ""
|
||||||
ImageUrl = defaultArg episode.ImageUrl ""
|
ImageUrl = defaultArg episode.ImageUrl ""
|
||||||
Subtitle = defaultArg episode.Subtitle ""
|
Subtitle = defaultArg episode.Subtitle ""
|
||||||
@@ -736,7 +782,8 @@ type EditPostModel =
|
|||||||
Some {
|
Some {
|
||||||
Media = this.Media
|
Media = this.Media
|
||||||
Length = this.Length
|
Length = this.Length
|
||||||
Duration = noneIfBlank this.Duration |> Option.map TimeSpan.Parse
|
Duration = noneIfBlank this.Duration
|
||||||
|
|> Option.map (TimeSpan.Parse >> Duration.FromTimeSpan)
|
||||||
MediaType = noneIfBlank this.MediaType
|
MediaType = noneIfBlank this.MediaType
|
||||||
ImageUrl = noneIfBlank this.ImageUrl
|
ImageUrl = noneIfBlank this.ImageUrl
|
||||||
Subtitle = noneIfBlank this.Subtitle
|
Subtitle = noneIfBlank this.Subtitle
|
||||||
@@ -1027,7 +1074,7 @@ type PostListItem =
|
|||||||
Permalink = Permalink.toString post.Permalink
|
Permalink = Permalink.toString post.Permalink
|
||||||
PublishedOn = post.PublishedOn |> Option.map inTZ |> Option.toNullable
|
PublishedOn = post.PublishedOn |> Option.map inTZ |> Option.toNullable
|
||||||
UpdatedOn = inTZ post.UpdatedOn
|
UpdatedOn = inTZ post.UpdatedOn
|
||||||
Text = if extra = "" then post.Text else post.Text.Replace ("href=\"/", $"href=\"{extra}/")
|
Text = addBaseToRelativeUrls extra post.Text
|
||||||
CategoryIds = post.CategoryIds |> List.map CategoryId.toString
|
CategoryIds = post.CategoryIds |> List.map CategoryId.toString
|
||||||
Tags = post.Tags
|
Tags = post.Tags
|
||||||
Episode = post.Episode
|
Episode = post.Episode
|
||||||
@@ -1127,6 +1174,14 @@ type UploadFileModel =
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// View model for uploading a theme
|
||||||
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
|
type UploadThemeModel =
|
||||||
|
{ /// Whether the uploaded theme should overwrite an existing theme
|
||||||
|
DoOverwrite : bool
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// A message displayed to the user
|
/// A message displayed to the user
|
||||||
[<CLIMutable; NoComparison; NoEquality>]
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
type UserMessage =
|
type UserMessage =
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ module Extensions =
|
|||||||
defaultArg (this.UserAccessLevel |> Option.map (AccessLevel.hasAccess level)) false
|
defaultArg (this.UserAccessLevel |> Option.map (AccessLevel.hasAccess level)) false
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
open System.Collections.Concurrent
|
open System.Collections.Concurrent
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -80,11 +79,19 @@ module WebLogCache =
|
|||||||
let set webLog =
|
let set webLog =
|
||||||
_cache <- webLog :: (_cache |> List.filter (fun wl -> wl.Id <> webLog.Id))
|
_cache <- webLog :: (_cache |> List.filter (fun wl -> wl.Id <> webLog.Id))
|
||||||
|
|
||||||
|
/// Get all cached web logs
|
||||||
|
let all () =
|
||||||
|
_cache
|
||||||
|
|
||||||
/// Fill the web log cache from the database
|
/// Fill the web log cache from the database
|
||||||
let fill (data : IData) = backgroundTask {
|
let fill (data : IData) = backgroundTask {
|
||||||
let! webLogs = data.WebLog.All ()
|
let! webLogs = data.WebLog.All ()
|
||||||
_cache <- webLogs
|
_cache <- webLogs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Is the given theme in use by any web logs?
|
||||||
|
let isThemeInUse themeId =
|
||||||
|
_cache |> List.exists (fun wl -> wl.ThemeId = themeId)
|
||||||
|
|
||||||
|
|
||||||
/// A cache of page information needed to display the page list in templates
|
/// A cache of page information needed to display the page list in templates
|
||||||
@@ -93,22 +100,30 @@ module PageListCache =
|
|||||||
open MyWebLog.ViewModels
|
open MyWebLog.ViewModels
|
||||||
|
|
||||||
/// Cache of displayed pages
|
/// Cache of displayed pages
|
||||||
let private _cache = ConcurrentDictionary<string, DisplayPage[]> ()
|
let private _cache = ConcurrentDictionary<WebLogId, DisplayPage[]> ()
|
||||||
|
|
||||||
/// Are there pages cached for this web log?
|
let private fillPages (webLog : WebLog) pages =
|
||||||
let exists (ctx : HttpContext) = _cache.ContainsKey ctx.WebLog.UrlBase
|
_cache[webLog.Id] <-
|
||||||
|
|
||||||
/// Get the pages for the web log for this request
|
|
||||||
let get (ctx : HttpContext) = _cache[ctx.WebLog.UrlBase]
|
|
||||||
|
|
||||||
/// Update the pages for the current web log
|
|
||||||
let update (ctx : HttpContext) = backgroundTask {
|
|
||||||
let webLog = ctx.WebLog
|
|
||||||
let! pages = ctx.Data.Page.FindListed webLog.Id
|
|
||||||
_cache[webLog.UrlBase] <-
|
|
||||||
pages
|
pages
|
||||||
|> List.map (fun pg -> DisplayPage.fromPage webLog { pg with Text = "" })
|
|> List.map (fun pg -> DisplayPage.fromPage webLog { pg with Text = "" })
|
||||||
|> Array.ofList
|
|> Array.ofList
|
||||||
|
|
||||||
|
/// Are there pages cached for this web log?
|
||||||
|
let exists (ctx : HttpContext) = _cache.ContainsKey ctx.WebLog.Id
|
||||||
|
|
||||||
|
/// Get the pages for the web log for this request
|
||||||
|
let get (ctx : HttpContext) = _cache[ctx.WebLog.Id]
|
||||||
|
|
||||||
|
/// Update the pages for the current web log
|
||||||
|
let update (ctx : HttpContext) = backgroundTask {
|
||||||
|
let! pages = ctx.Data.Page.FindListed ctx.WebLog.Id
|
||||||
|
fillPages ctx.WebLog pages
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh the pages for the given web log
|
||||||
|
let refresh (webLog : WebLog) (data : IData) = backgroundTask {
|
||||||
|
let! pages = data.Page.FindListed webLog.Id
|
||||||
|
fillPages webLog pages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -118,18 +133,24 @@ module CategoryCache =
|
|||||||
open MyWebLog.ViewModels
|
open MyWebLog.ViewModels
|
||||||
|
|
||||||
/// The cache itself
|
/// The cache itself
|
||||||
let private _cache = ConcurrentDictionary<string, DisplayCategory[]> ()
|
let private _cache = ConcurrentDictionary<WebLogId, DisplayCategory[]> ()
|
||||||
|
|
||||||
/// Are there categories cached for this web log?
|
/// Are there categories cached for this web log?
|
||||||
let exists (ctx : HttpContext) = _cache.ContainsKey ctx.WebLog.UrlBase
|
let exists (ctx : HttpContext) = _cache.ContainsKey ctx.WebLog.Id
|
||||||
|
|
||||||
/// Get the categories for the web log for this request
|
/// Get the categories for the web log for this request
|
||||||
let get (ctx : HttpContext) = _cache[ctx.WebLog.UrlBase]
|
let get (ctx : HttpContext) = _cache[ctx.WebLog.Id]
|
||||||
|
|
||||||
/// Update the cache with fresh data
|
/// Update the cache with fresh data
|
||||||
let update (ctx : HttpContext) = backgroundTask {
|
let update (ctx : HttpContext) = backgroundTask {
|
||||||
let! cats = ctx.Data.Category.FindAllForView ctx.WebLog.Id
|
let! cats = ctx.Data.Category.FindAllForView ctx.WebLog.Id
|
||||||
_cache[ctx.WebLog.UrlBase] <- cats
|
_cache[ctx.WebLog.Id] <- cats
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh the category cache for the given web log
|
||||||
|
let refresh webLogId (data : IData) = backgroundTask {
|
||||||
|
let! cats = data.Category.FindAllForView webLogId
|
||||||
|
_cache[webLogId] <- cats
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -147,29 +168,54 @@ module TemplateCache =
|
|||||||
let private hasInclude = Regex ("""{% include_template \"(.*)\" %}""", RegexOptions.None, TimeSpan.FromSeconds 2)
|
let private hasInclude = Regex ("""{% include_template \"(.*)\" %}""", RegexOptions.None, TimeSpan.FromSeconds 2)
|
||||||
|
|
||||||
/// Get a template for the given theme and template name
|
/// Get a template for the given theme and template name
|
||||||
let get (themeId : string) (templateName : string) (data : IData) = backgroundTask {
|
let get (themeId : ThemeId) (templateName : string) (data : IData) = backgroundTask {
|
||||||
let templatePath = $"{themeId}/{templateName}"
|
let templatePath = $"{ThemeId.toString themeId}/{templateName}"
|
||||||
match _cache.ContainsKey templatePath with
|
match _cache.ContainsKey templatePath with
|
||||||
| true -> ()
|
| true -> return Ok _cache[templatePath]
|
||||||
| false ->
|
| false ->
|
||||||
match! data.Theme.FindById (ThemeId themeId) with
|
match! data.Theme.FindById themeId with
|
||||||
| Some theme ->
|
| Some theme ->
|
||||||
let mutable text = (theme.Templates |> List.find (fun t -> t.Name = templateName)).Text
|
match theme.Templates |> List.tryFind (fun t -> t.Name = templateName) with
|
||||||
while hasInclude.IsMatch text do
|
| Some template ->
|
||||||
let child = hasInclude.Match text
|
let mutable text = template.Text
|
||||||
let childText = (theme.Templates |> List.find (fun t -> t.Name = child.Groups[1].Value)).Text
|
let mutable childNotFound = ""
|
||||||
text <- text.Replace (child.Value, childText)
|
while hasInclude.IsMatch text do
|
||||||
_cache[templatePath] <- Template.Parse (text, SyntaxCompatibility.DotLiquid22)
|
let child = hasInclude.Match text
|
||||||
| None -> ()
|
let childText =
|
||||||
return _cache[templatePath]
|
match theme.Templates |> List.tryFind (fun t -> t.Name = child.Groups[1].Value) with
|
||||||
|
| Some childTemplate -> childTemplate.Text
|
||||||
|
| None ->
|
||||||
|
childNotFound <-
|
||||||
|
if childNotFound = "" then child.Groups[1].Value
|
||||||
|
else $"{childNotFound}; {child.Groups[1].Value}"
|
||||||
|
""
|
||||||
|
text <- text.Replace (child.Value, childText)
|
||||||
|
if childNotFound <> "" then
|
||||||
|
let s = if childNotFound.IndexOf ";" >= 0 then "s" else ""
|
||||||
|
return Error $"Could not find the child template{s} {childNotFound} required by {templateName}"
|
||||||
|
else
|
||||||
|
_cache[templatePath] <- Template.Parse (text, SyntaxCompatibility.DotLiquid22)
|
||||||
|
return Ok _cache[templatePath]
|
||||||
|
| None ->
|
||||||
|
return Error $"Theme ID {ThemeId.toString themeId} does not have a template named {templateName}"
|
||||||
|
| None -> return Result.Error $"Theme ID {ThemeId.toString themeId} does not exist"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get all theme/template names currently cached
|
||||||
|
let allNames () =
|
||||||
|
_cache.Keys |> Seq.sort |> Seq.toList
|
||||||
|
|
||||||
/// Invalidate all template cache entries for the given theme ID
|
/// Invalidate all template cache entries for the given theme ID
|
||||||
let invalidateTheme (themeId : string) =
|
let invalidateTheme (themeId : ThemeId) =
|
||||||
|
let keyPrefix = ThemeId.toString themeId
|
||||||
_cache.Keys
|
_cache.Keys
|
||||||
|> Seq.filter (fun key -> key.StartsWith themeId)
|
|> Seq.filter (fun key -> key.StartsWith keyPrefix)
|
||||||
|> List.ofSeq
|
|> List.ofSeq
|
||||||
|> List.iter (fun key -> match _cache.TryRemove key with _, _ -> ())
|
|> List.iter (fun key -> match _cache.TryRemove key with _, _ -> ())
|
||||||
|
|
||||||
|
/// Remove all entries from the template cache
|
||||||
|
let empty () =
|
||||||
|
_cache.Clear ()
|
||||||
|
|
||||||
|
|
||||||
/// A cache of asset names by themes
|
/// A cache of asset names by themes
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ type UserLinksTag () =
|
|||||||
|> Seq.iter result.WriteLine
|
|> Seq.iter result.WriteLine
|
||||||
|
|
||||||
/// A filter to retrieve the value of a meta item from a list
|
/// A filter to retrieve the value of a meta item from a list
|
||||||
// (shorter than `{% assign item = list | where: "name", [name] | first %}{{ item.value }}`)
|
// (shorter than `{% assign item = list | where: "Name", [name] | first %}{{ item.value }}`)
|
||||||
type ValueFilter () =
|
type ValueFilter () =
|
||||||
static member Value (_ : Context, items : MetaItem list, name : string) =
|
static member Value (_ : Context, items : MetaItem list, name : string) =
|
||||||
match items |> List.tryFind (fun it -> it.Name = name) with
|
match items |> List.tryFind (fun it -> it.Name = name) with
|
||||||
@@ -227,12 +227,12 @@ let register () =
|
|||||||
typeof<CustomFeed>; typeof<Episode>; typeof<Episode option>; typeof<MetaItem>; typeof<Page>
|
typeof<CustomFeed>; typeof<Episode>; typeof<Episode option>; typeof<MetaItem>; typeof<Page>
|
||||||
typeof<RssOptions>; typeof<TagMap>; typeof<UploadDestination>; typeof<WebLog>
|
typeof<RssOptions>; typeof<TagMap>; typeof<UploadDestination>; typeof<WebLog>
|
||||||
// View models
|
// View models
|
||||||
typeof<DashboardModel>; typeof<DisplayCategory>; typeof<DisplayCustomFeed>; typeof<DisplayPage>
|
typeof<DashboardModel>; typeof<DisplayCategory>; typeof<DisplayCustomFeed>; typeof<DisplayPage>
|
||||||
typeof<DisplayRevision>; typeof<DisplayUpload>; typeof<DisplayUser>; typeof<EditCategoryModel>
|
typeof<DisplayRevision>; typeof<DisplayTheme>; typeof<DisplayUpload>; typeof<DisplayUser>
|
||||||
typeof<EditCustomFeedModel>; typeof<EditMyInfoModel>; typeof<EditPageModel>; typeof<EditPostModel>
|
typeof<EditCategoryModel>; typeof<EditCustomFeedModel>; typeof<EditMyInfoModel>; typeof<EditPageModel>
|
||||||
typeof<EditRssModel>; typeof<EditTagMapModel>; typeof<EditUserModel>; typeof<LogOnModel>
|
typeof<EditPostModel>; typeof<EditRssModel>; typeof<EditTagMapModel>; typeof<EditUserModel>
|
||||||
typeof<ManagePermalinksModel>; typeof<ManageRevisionsModel>; typeof<PostDisplay>; typeof<PostListItem>
|
typeof<LogOnModel>; typeof<ManagePermalinksModel>; typeof<ManageRevisionsModel>; typeof<PostDisplay>
|
||||||
typeof<SettingsModel>; typeof<UserMessage>
|
typeof<PostListItem>; typeof<SettingsModel>; typeof<UserMessage>
|
||||||
// Framework types
|
// Framework types
|
||||||
typeof<AntiforgeryTokenSet>; typeof<DateTime option>; typeof<int option>; typeof<KeyValuePair>
|
typeof<AntiforgeryTokenSet>; typeof<DateTime option>; typeof<int option>; typeof<KeyValuePair>
|
||||||
typeof<MetaItem list>; typeof<string list>; typeof<string option>; typeof<TagMap list>
|
typeof<MetaItem list>; typeof<string list>; typeof<string option>; typeof<TagMap list>
|
||||||
|
|||||||
@@ -5,339 +5,515 @@ open System.Threading.Tasks
|
|||||||
open Giraffe
|
open Giraffe
|
||||||
open MyWebLog
|
open MyWebLog
|
||||||
open MyWebLog.ViewModels
|
open MyWebLog.ViewModels
|
||||||
|
open NodaTime
|
||||||
|
|
||||||
// GET /admin
|
/// ~~ DASHBOARDS ~~
|
||||||
let dashboard : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
module Dashboard =
|
||||||
let getCount (f : WebLogId -> Task<int>) = f ctx.WebLog.Id
|
|
||||||
let data = ctx.Data
|
// GET /admin/dashboard
|
||||||
let posts = getCount (data.Post.CountByStatus Published)
|
let user : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
||||||
let drafts = getCount (data.Post.CountByStatus Draft)
|
let getCount (f : WebLogId -> Task<int>) = f ctx.WebLog.Id
|
||||||
let pages = getCount data.Page.CountAll
|
let data = ctx.Data
|
||||||
let listed = getCount data.Page.CountListed
|
let! posts = getCount (data.Post.CountByStatus Published)
|
||||||
let cats = getCount data.Category.CountAll
|
let! drafts = getCount (data.Post.CountByStatus Draft)
|
||||||
let topCats = getCount data.Category.CountTopLevel
|
let! pages = getCount data.Page.CountAll
|
||||||
let! _ = Task.WhenAll (posts, drafts, pages, listed, cats, topCats)
|
let! listed = getCount data.Page.CountListed
|
||||||
return!
|
let! cats = getCount data.Category.CountAll
|
||||||
hashForPage "Dashboard"
|
let! topCats = getCount data.Category.CountTopLevel
|
||||||
|> addToHash ViewContext.Model {
|
return!
|
||||||
Posts = posts.Result
|
hashForPage "Dashboard"
|
||||||
Drafts = drafts.Result
|
|> addToHash ViewContext.Model {
|
||||||
Pages = pages.Result
|
Posts = posts
|
||||||
ListedPages = listed.Result
|
Drafts = drafts
|
||||||
Categories = cats.Result
|
Pages = pages
|
||||||
TopLevelCategories = topCats.Result
|
ListedPages = listed
|
||||||
}
|
Categories = cats
|
||||||
|> adminView "dashboard" next ctx
|
TopLevelCategories = topCats
|
||||||
}
|
}
|
||||||
|
|> adminView "dashboard" next ctx
|
||||||
|
}
|
||||||
|
|
||||||
// -- CATEGORIES --
|
// GET /admin/administration
|
||||||
|
let admin : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
|
||||||
|
match! TemplateCache.get adminTheme "theme-list-body" ctx.Data with
|
||||||
|
| Ok bodyTemplate ->
|
||||||
|
let! themes = ctx.Data.Theme.All ()
|
||||||
|
let cachedTemplates = TemplateCache.allNames ()
|
||||||
|
let! hash =
|
||||||
|
hashForPage "myWebLog Administration"
|
||||||
|
|> withAntiCsrf ctx
|
||||||
|
|> addToHash "themes" (
|
||||||
|
themes
|
||||||
|
|> List.map (DisplayTheme.fromTheme WebLogCache.isThemeInUse)
|
||||||
|
|> Array.ofList)
|
||||||
|
|> addToHash "cached_themes" (
|
||||||
|
themes
|
||||||
|
|> Seq.ofList
|
||||||
|
|> Seq.map (fun it -> [|
|
||||||
|
ThemeId.toString it.Id
|
||||||
|
it.Name
|
||||||
|
cachedTemplates
|
||||||
|
|> List.filter (fun n -> n.StartsWith (ThemeId.toString it.Id))
|
||||||
|
|> List.length
|
||||||
|
|> string
|
||||||
|
|])
|
||||||
|
|> Array.ofSeq)
|
||||||
|
|> addToHash "web_logs" (
|
||||||
|
WebLogCache.all ()
|
||||||
|
|> Seq.ofList
|
||||||
|
|> Seq.sortBy (fun it -> it.Name)
|
||||||
|
|> Seq.map (fun it -> [| WebLogId.toString it.Id; it.Name; it.UrlBase |])
|
||||||
|
|> Array.ofSeq)
|
||||||
|
|> addViewContext ctx
|
||||||
|
return!
|
||||||
|
addToHash "theme_list" (bodyTemplate.Render hash) hash
|
||||||
|
|> adminView "admin-dashboard" next ctx
|
||||||
|
| Error message -> return! Error.server message next ctx
|
||||||
|
}
|
||||||
|
|
||||||
// GET /admin/categories
|
/// Redirect the user to the admin dashboard
|
||||||
let listCategories : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
let toAdminDashboard : HttpHandler = redirectToGet "admin/administration"
|
||||||
let! catListTemplate = TemplateCache.get "admin" "category-list-body" ctx.Data
|
|
||||||
let! hash =
|
|
||||||
|
/// ~~ CACHES ~~
|
||||||
|
module Cache =
|
||||||
|
|
||||||
|
// POST /admin/cache/web-log/{id}/refresh
|
||||||
|
let refreshWebLog webLogId : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
|
||||||
|
let data = ctx.Data
|
||||||
|
if webLogId = "all" then
|
||||||
|
do! WebLogCache.fill data
|
||||||
|
for webLog in WebLogCache.all () do
|
||||||
|
do! PageListCache.refresh webLog data
|
||||||
|
do! CategoryCache.refresh webLog.Id data
|
||||||
|
do! addMessage ctx
|
||||||
|
{ UserMessage.success with Message = "Successfully refresh web log cache for all web logs" }
|
||||||
|
else
|
||||||
|
match! data.WebLog.FindById (WebLogId webLogId) with
|
||||||
|
| Some webLog ->
|
||||||
|
WebLogCache.set webLog
|
||||||
|
do! PageListCache.refresh webLog data
|
||||||
|
do! CategoryCache.refresh webLog.Id data
|
||||||
|
do! addMessage ctx
|
||||||
|
{ UserMessage.success with Message = $"Successfully refreshed web log cache for {webLog.Name}" }
|
||||||
|
| None ->
|
||||||
|
do! addMessage ctx { UserMessage.error with Message = $"No web log exists with ID {webLogId}" }
|
||||||
|
return! toAdminDashboard next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/cache/theme/{id}/refresh
|
||||||
|
let refreshTheme themeId : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
|
||||||
|
let data = ctx.Data
|
||||||
|
if themeId = "all" then
|
||||||
|
TemplateCache.empty ()
|
||||||
|
do! ThemeAssetCache.fill data
|
||||||
|
do! addMessage ctx
|
||||||
|
{ UserMessage.success with
|
||||||
|
Message = "Successfully cleared template cache and refreshed theme asset cache"
|
||||||
|
}
|
||||||
|
else
|
||||||
|
match! data.Theme.FindById (ThemeId themeId) with
|
||||||
|
| Some theme ->
|
||||||
|
TemplateCache.invalidateTheme theme.Id
|
||||||
|
do! ThemeAssetCache.refreshTheme theme.Id data
|
||||||
|
do! addMessage ctx
|
||||||
|
{ UserMessage.success with
|
||||||
|
Message = $"Successfully cleared template cache and refreshed theme asset cache for {theme.Name}"
|
||||||
|
}
|
||||||
|
| None ->
|
||||||
|
do! addMessage ctx { UserMessage.error with Message = $"No theme exists with ID {themeId}" }
|
||||||
|
return! toAdminDashboard next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// ~~ CATEGORIES ~~
|
||||||
|
module Category =
|
||||||
|
|
||||||
|
open MyWebLog.Data
|
||||||
|
|
||||||
|
// GET /admin/categories
|
||||||
|
let all : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||||
|
match! TemplateCache.get adminTheme "category-list-body" ctx.Data with
|
||||||
|
| Ok catListTemplate ->
|
||||||
|
let! hash =
|
||||||
|
hashForPage "Categories"
|
||||||
|
|> withAntiCsrf ctx
|
||||||
|
|> addViewContext ctx
|
||||||
|
return!
|
||||||
|
addToHash "category_list" (catListTemplate.Render hash) hash
|
||||||
|
|> adminView "category-list" next ctx
|
||||||
|
| Error message -> return! Error.server message next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/categories/bare
|
||||||
|
let bare : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx ->
|
||||||
hashForPage "Categories"
|
hashForPage "Categories"
|
||||||
|> withAntiCsrf ctx
|
|> withAntiCsrf ctx
|
||||||
|> addViewContext ctx
|
|> adminBareView "category-list-body" next ctx
|
||||||
return!
|
|
||||||
addToHash "category_list" (catListTemplate.Render hash) hash
|
|
||||||
|> adminView "category-list" next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /admin/categories/bare
|
|
||||||
let listCategoriesBare : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx ->
|
|
||||||
hashForPage "Categories"
|
|
||||||
|> withAntiCsrf ctx
|
|
||||||
|> adminBareView "category-list-body" next ctx
|
|
||||||
|
|
||||||
|
|
||||||
// GET /admin/category/{id}/edit
|
// GET /admin/category/{id}/edit
|
||||||
let editCategory catId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
let edit catId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||||
let! result = task {
|
let! result = task {
|
||||||
match catId with
|
match catId with
|
||||||
| "new" -> return Some ("Add a New Category", { Category.empty with Id = CategoryId "new" })
|
| "new" -> return Some ("Add a New Category", { Category.empty with Id = CategoryId "new" })
|
||||||
| _ ->
|
| _ ->
|
||||||
match! ctx.Data.Category.FindById (CategoryId catId) ctx.WebLog.Id with
|
match! ctx.Data.Category.FindById (CategoryId catId) ctx.WebLog.Id with
|
||||||
| Some cat -> return Some ("Edit Category", cat)
|
| Some cat -> return Some ("Edit Category", cat)
|
||||||
| None -> return None
|
| None -> return None
|
||||||
|
}
|
||||||
|
match result with
|
||||||
|
| Some (title, cat) ->
|
||||||
|
return!
|
||||||
|
hashForPage title
|
||||||
|
|> withAntiCsrf ctx
|
||||||
|
|> addToHash ViewContext.Model (EditCategoryModel.fromCategory cat)
|
||||||
|
|> adminBareView "category-edit" next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
match result with
|
|
||||||
| Some (title, cat) ->
|
|
||||||
return!
|
|
||||||
hashForPage title
|
|
||||||
|> withAntiCsrf ctx
|
|
||||||
|> addToHash ViewContext.Model (EditCategoryModel.fromCategory cat)
|
|
||||||
|> adminBareView "category-edit" next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /admin/category/save
|
// POST /admin/category/save
|
||||||
let saveCategory : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
let save : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||||
let data = ctx.Data
|
let data = ctx.Data
|
||||||
let! model = ctx.BindFormAsync<EditCategoryModel> ()
|
let! model = ctx.BindFormAsync<EditCategoryModel> ()
|
||||||
let category =
|
let category =
|
||||||
if model.IsNew then someTask { Category.empty with Id = CategoryId.create (); WebLogId = ctx.WebLog.Id }
|
if model.IsNew then someTask { Category.empty with Id = CategoryId.create (); WebLogId = ctx.WebLog.Id }
|
||||||
else data.Category.FindById (CategoryId model.CategoryId) ctx.WebLog.Id
|
else data.Category.FindById (CategoryId model.CategoryId) ctx.WebLog.Id
|
||||||
match! category with
|
match! category with
|
||||||
| Some cat ->
|
| Some cat ->
|
||||||
let updatedCat =
|
let updatedCat =
|
||||||
{ cat with
|
{ cat with
|
||||||
Name = model.Name
|
Name = model.Name
|
||||||
Slug = model.Slug
|
Slug = model.Slug
|
||||||
Description = if model.Description = "" then None else Some model.Description
|
Description = if model.Description = "" then None else Some model.Description
|
||||||
ParentId = if model.ParentId = "" then None else Some (CategoryId model.ParentId)
|
ParentId = if model.ParentId = "" then None else Some (CategoryId model.ParentId)
|
||||||
}
|
}
|
||||||
do! (if model.IsNew then data.Category.Add else data.Category.Update) updatedCat
|
do! (if model.IsNew then data.Category.Add else data.Category.Update) updatedCat
|
||||||
do! CategoryCache.update ctx
|
do! CategoryCache.update ctx
|
||||||
do! addMessage ctx { UserMessage.success with Message = "Category saved successfully" }
|
do! addMessage ctx { UserMessage.success with Message = "Category saved successfully" }
|
||||||
return! listCategoriesBare next ctx
|
return! bare next ctx
|
||||||
| None -> return! Error.notFound next ctx
|
| None -> return! Error.notFound next ctx
|
||||||
}
|
|
||||||
|
|
||||||
// POST /admin/category/{id}/delete
|
|
||||||
let deleteCategory catId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
|
||||||
match! ctx.Data.Category.Delete (CategoryId catId) ctx.WebLog.Id with
|
|
||||||
| true ->
|
|
||||||
do! CategoryCache.update ctx
|
|
||||||
do! addMessage ctx { UserMessage.success with Message = "Category deleted successfully" }
|
|
||||||
| false -> do! addMessage ctx { UserMessage.error with Message = "Category not found; cannot delete" }
|
|
||||||
return! listCategoriesBare next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
open Microsoft.AspNetCore.Http
|
|
||||||
|
|
||||||
// -- TAG MAPPINGS --
|
|
||||||
|
|
||||||
/// Get the hash necessary to render the tag mapping list
|
|
||||||
let private tagMappingHash (ctx : HttpContext) = task {
|
|
||||||
let! mappings = ctx.Data.TagMap.FindByWebLog ctx.WebLog.Id
|
|
||||||
return!
|
|
||||||
hashForPage "Tag Mappings"
|
|
||||||
|> withAntiCsrf ctx
|
|
||||||
|> addToHash "mappings" mappings
|
|
||||||
|> addToHash "mapping_ids" (mappings |> List.map (fun it -> { Name = it.Tag; Value = TagMapId.toString it.Id }))
|
|
||||||
|> addViewContext ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /admin/settings/tag-mappings
|
|
||||||
let tagMappings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
|
||||||
let! hash = tagMappingHash ctx
|
|
||||||
let! listTemplate = TemplateCache.get "admin" "tag-mapping-list-body" ctx.Data
|
|
||||||
return!
|
|
||||||
addToHash "tag_mapping_list" (listTemplate.Render hash) hash
|
|
||||||
|> adminView "tag-mapping-list" next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /admin/settings/tag-mappings/bare
|
|
||||||
let tagMappingsBare : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
|
||||||
let! hash = tagMappingHash ctx
|
|
||||||
return! adminBareView "tag-mapping-list-body" next ctx hash
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /admin/settings/tag-mapping/{id}/edit
|
|
||||||
let editMapping tagMapId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
|
||||||
let isNew = tagMapId = "new"
|
|
||||||
let tagMap =
|
|
||||||
if isNew then someTask { TagMap.empty with Id = TagMapId "new" }
|
|
||||||
else ctx.Data.TagMap.FindById (TagMapId tagMapId) ctx.WebLog.Id
|
|
||||||
match! tagMap with
|
|
||||||
| Some tm ->
|
|
||||||
return!
|
|
||||||
hashForPage (if isNew then "Add Tag Mapping" else $"Mapping for {tm.Tag} Tag")
|
|
||||||
|> withAntiCsrf ctx
|
|
||||||
|> addToHash ViewContext.Model (EditTagMapModel.fromMapping tm)
|
|
||||||
|> adminBareView "tag-mapping-edit" next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /admin/settings/tag-mapping/save
|
|
||||||
let saveMapping : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
|
||||||
let data = ctx.Data
|
|
||||||
let! model = ctx.BindFormAsync<EditTagMapModel> ()
|
|
||||||
let tagMap =
|
|
||||||
if model.IsNew then someTask { TagMap.empty with Id = TagMapId.create (); WebLogId = ctx.WebLog.Id }
|
|
||||||
else data.TagMap.FindById (TagMapId model.Id) ctx.WebLog.Id
|
|
||||||
match! tagMap with
|
|
||||||
| Some tm ->
|
|
||||||
do! data.TagMap.Save { tm with Tag = model.Tag.ToLower (); UrlValue = model.UrlValue.ToLower () }
|
|
||||||
do! addMessage ctx { UserMessage.success with Message = "Tag mapping saved successfully" }
|
|
||||||
return! tagMappingsBare next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /admin/settings/tag-mapping/{id}/delete
|
|
||||||
let deleteMapping tagMapId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
|
||||||
match! ctx.Data.TagMap.Delete (TagMapId tagMapId) ctx.WebLog.Id with
|
|
||||||
| true -> do! addMessage ctx { UserMessage.success with Message = "Tag mapping deleted successfully" }
|
|
||||||
| false -> do! addMessage ctx { UserMessage.error with Message = "Tag mapping not found; nothing deleted" }
|
|
||||||
return! tagMappingsBare next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- THEMES --
|
|
||||||
|
|
||||||
open System
|
|
||||||
open System.IO
|
|
||||||
open System.IO.Compression
|
|
||||||
open System.Text.RegularExpressions
|
|
||||||
open MyWebLog.Data
|
|
||||||
|
|
||||||
// GET /admin/theme/update
|
|
||||||
let themeUpdatePage : HttpHandler = requireAccess Administrator >=> fun next ctx ->
|
|
||||||
hashForPage "Upload Theme"
|
|
||||||
|> withAntiCsrf ctx
|
|
||||||
|> adminView "upload-theme" next ctx
|
|
||||||
|
|
||||||
/// Update the name and version for a theme based on the version.txt file, if present
|
|
||||||
let private updateNameAndVersion (theme : Theme) (zip : ZipArchive) = backgroundTask {
|
|
||||||
let now () = DateTime.UtcNow.ToString "yyyyMMdd.HHmm"
|
|
||||||
match zip.Entries |> Seq.filter (fun it -> it.FullName = "version.txt") |> Seq.tryHead with
|
|
||||||
| Some versionItem ->
|
|
||||||
use versionFile = new StreamReader(versionItem.Open ())
|
|
||||||
let! versionText = versionFile.ReadToEndAsync ()
|
|
||||||
let parts = versionText.Trim().Replace("\r", "").Split "\n"
|
|
||||||
let displayName = if parts[0] > "" then parts[0] else ThemeId.toString theme.Id
|
|
||||||
let version = if parts.Length > 1 && parts[1] > "" then parts[1] else now ()
|
|
||||||
return { theme with Name = displayName; Version = version }
|
|
||||||
| None -> return { theme with Name = ThemeId.toString theme.Id; Version = now () }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete all theme assets, and remove templates from theme
|
|
||||||
let private checkForCleanLoad (theme : Theme) cleanLoad (data : IData) = backgroundTask {
|
|
||||||
if cleanLoad then
|
|
||||||
do! data.ThemeAsset.DeleteByTheme theme.Id
|
|
||||||
return { theme with Templates = [] }
|
|
||||||
else return theme
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the theme with all templates from the ZIP archive
|
|
||||||
let private updateTemplates (theme : Theme) (zip : ZipArchive) = backgroundTask {
|
|
||||||
let tasks =
|
|
||||||
zip.Entries
|
|
||||||
|> Seq.filter (fun it -> it.Name.EndsWith ".liquid")
|
|
||||||
|> Seq.map (fun templateItem -> backgroundTask {
|
|
||||||
use templateFile = new StreamReader (templateItem.Open ())
|
|
||||||
let! template = templateFile.ReadToEndAsync ()
|
|
||||||
return { Name = templateItem.Name.Replace (".liquid", ""); Text = template }
|
|
||||||
})
|
|
||||||
let! templates = Task.WhenAll tasks
|
|
||||||
return
|
|
||||||
templates
|
|
||||||
|> Array.fold (fun t template ->
|
|
||||||
{ t with Templates = template :: (t.Templates |> List.filter (fun it -> it.Name <> template.Name)) })
|
|
||||||
theme
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update theme assets from the ZIP archive
|
|
||||||
let private updateAssets themeId (zip : ZipArchive) (data : IData) = backgroundTask {
|
|
||||||
for asset in zip.Entries |> Seq.filter (fun it -> it.FullName.StartsWith "wwwroot") do
|
|
||||||
let assetName = asset.FullName.Replace ("wwwroot/", "")
|
|
||||||
if assetName <> "" && not (assetName.EndsWith "/") then
|
|
||||||
use stream = new MemoryStream ()
|
|
||||||
do! asset.Open().CopyToAsync stream
|
|
||||||
do! data.ThemeAsset.Save
|
|
||||||
{ Id = ThemeAssetId (themeId, assetName)
|
|
||||||
UpdatedOn = asset.LastWriteTime.DateTime
|
|
||||||
Data = stream.ToArray ()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the theme name from the file name given
|
|
||||||
let getThemeName (fileName : string) =
|
|
||||||
let themeName = fileName.Split(".").[0].ToLowerInvariant().Replace (" ", "-")
|
|
||||||
if Regex.IsMatch (themeName, """^[a-z0-9\-]+$""") then Ok themeName else Error $"Theme name {fileName} is invalid"
|
|
||||||
|
|
||||||
/// Load a theme from the given stream, which should contain a ZIP archive
|
|
||||||
let loadThemeFromZip themeName file clean (data : IData) = backgroundTask {
|
|
||||||
use zip = new ZipArchive (file, ZipArchiveMode.Read)
|
|
||||||
let themeId = ThemeId themeName
|
|
||||||
let! theme = backgroundTask {
|
|
||||||
match! data.Theme.FindById themeId with
|
|
||||||
| Some t -> return t
|
|
||||||
| None -> return { Theme.empty with Id = themeId }
|
|
||||||
}
|
}
|
||||||
let! theme = updateNameAndVersion theme zip
|
|
||||||
let! theme = checkForCleanLoad theme clean data
|
|
||||||
let! theme = updateTemplates theme zip
|
|
||||||
do! data.Theme.Save theme
|
|
||||||
do! updateAssets themeId zip data
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /admin/theme/update
|
// POST /admin/category/{id}/delete
|
||||||
let updateTheme : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
|
let delete catId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||||
if ctx.Request.HasFormContentType && ctx.Request.Form.Files.Count > 0 then
|
let! result = ctx.Data.Category.Delete (CategoryId catId) ctx.WebLog.Id
|
||||||
let themeFile = Seq.head ctx.Request.Form.Files
|
match result with
|
||||||
match getThemeName themeFile.FileName with
|
| CategoryDeleted
|
||||||
| Ok themeName when themeName <> "admin" ->
|
| ReassignedChildCategories ->
|
||||||
let data = ctx.Data
|
do! CategoryCache.update ctx
|
||||||
use stream = new MemoryStream ()
|
let detail =
|
||||||
do! themeFile.CopyToAsync stream
|
match result with
|
||||||
do! loadThemeFromZip themeName stream true data
|
| ReassignedChildCategories ->
|
||||||
do! ThemeAssetCache.refreshTheme (ThemeId themeName) data
|
Some "<em>(Its child categories were reassigned to its parent category)</em>"
|
||||||
TemplateCache.invalidateTheme themeName
|
| _ -> None
|
||||||
do! addMessage ctx { UserMessage.success with Message = "Theme updated successfully" }
|
do! addMessage ctx { UserMessage.success with Message = "Category deleted successfully"; Detail = detail }
|
||||||
return! redirectToGet "admin/dashboard" next ctx
|
| CategoryNotFound ->
|
||||||
| Ok _ ->
|
do! addMessage ctx { UserMessage.error with Message = "Category not found; cannot delete" }
|
||||||
do! addMessage ctx { UserMessage.error with Message = "You may not replace the admin theme" }
|
return! bare next ctx
|
||||||
return! redirectToGet "admin/theme/update" next ctx
|
}
|
||||||
| Error message ->
|
|
||||||
do! addMessage ctx { UserMessage.error with Message = message }
|
|
||||||
return! redirectToGet "admin/theme/update" next ctx
|
|
||||||
else return! RequestErrors.BAD_REQUEST "Bad request" next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- WEB LOG SETTINGS --
|
|
||||||
|
|
||||||
open System.Collections.Generic
|
/// ~~ TAG MAPPINGS ~~
|
||||||
|
module TagMapping =
|
||||||
// GET /admin/settings
|
|
||||||
let settings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
|
||||||
let data = ctx.Data
|
|
||||||
let! allPages = data.Page.All ctx.WebLog.Id
|
|
||||||
let! themes = data.Theme.All ()
|
|
||||||
return!
|
|
||||||
hashForPage "Web Log Settings"
|
|
||||||
|> withAntiCsrf ctx
|
|
||||||
|> addToHash ViewContext.Model (SettingsModel.fromWebLog ctx.WebLog)
|
|
||||||
|> addToHash "pages" (
|
|
||||||
seq {
|
|
||||||
KeyValuePair.Create ("posts", "- First Page of Posts -")
|
|
||||||
yield! allPages
|
|
||||||
|> List.sortBy (fun p -> p.Title.ToLower ())
|
|
||||||
|> List.map (fun p -> KeyValuePair.Create (PageId.toString p.Id, p.Title))
|
|
||||||
}
|
|
||||||
|> Array.ofSeq)
|
|
||||||
|> addToHash "themes" (
|
|
||||||
themes
|
|
||||||
|> Seq.ofList
|
|
||||||
|> Seq.map (fun it -> KeyValuePair.Create (ThemeId.toString it.Id, $"{it.Name} (v{it.Version})"))
|
|
||||||
|> Array.ofSeq)
|
|
||||||
|> addToHash "upload_values" [|
|
|
||||||
KeyValuePair.Create (UploadDestination.toString Database, "Database")
|
|
||||||
KeyValuePair.Create (UploadDestination.toString Disk, "Disk")
|
|
||||||
|]
|
|
||||||
|> adminView "settings" next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /admin/settings
|
|
||||||
let saveSettings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
|
||||||
let data = ctx.Data
|
|
||||||
let! model = ctx.BindFormAsync<SettingsModel> ()
|
|
||||||
match! data.WebLog.FindById ctx.WebLog.Id with
|
|
||||||
| Some webLog ->
|
|
||||||
let oldSlug = webLog.Slug
|
|
||||||
let webLog = model.update webLog
|
|
||||||
do! data.WebLog.UpdateSettings webLog
|
|
||||||
|
|
||||||
// Update cache
|
|
||||||
WebLogCache.set webLog
|
|
||||||
|
|
||||||
if oldSlug <> webLog.Slug then
|
|
||||||
// Rename disk directory if it exists
|
|
||||||
let uploadRoot = Path.Combine ("wwwroot", "upload")
|
|
||||||
let oldDir = Path.Combine (uploadRoot, oldSlug)
|
|
||||||
if Directory.Exists oldDir then Directory.Move (oldDir, Path.Combine (uploadRoot, webLog.Slug))
|
|
||||||
|
|
||||||
do! addMessage ctx { UserMessage.success with Message = "Web log settings saved successfully" }
|
open Microsoft.AspNetCore.Http
|
||||||
return! redirectToGet "admin/settings" next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
/// Add tag mappings to the given hash
|
||||||
}
|
let withTagMappings (ctx : HttpContext) hash = task {
|
||||||
|
let! mappings = ctx.Data.TagMap.FindByWebLog ctx.WebLog.Id
|
||||||
|
return
|
||||||
|
addToHash "mappings" mappings hash
|
||||||
|
|> addToHash "mapping_ids" (
|
||||||
|
mappings
|
||||||
|
|> List.map (fun it -> { Name = it.Tag; Value = TagMapId.toString it.Id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/settings/tag-mappings
|
||||||
|
let all : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||||
|
let! hash =
|
||||||
|
hashForPage ""
|
||||||
|
|> withAntiCsrf ctx
|
||||||
|
|> withTagMappings ctx
|
||||||
|
return! adminBareView "tag-mapping-list-body" next ctx hash
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/settings/tag-mapping/{id}/edit
|
||||||
|
let edit tagMapId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||||
|
let isNew = tagMapId = "new"
|
||||||
|
let tagMap =
|
||||||
|
if isNew then someTask { TagMap.empty with Id = TagMapId "new" }
|
||||||
|
else ctx.Data.TagMap.FindById (TagMapId tagMapId) ctx.WebLog.Id
|
||||||
|
match! tagMap with
|
||||||
|
| Some tm ->
|
||||||
|
return!
|
||||||
|
hashForPage (if isNew then "Add Tag Mapping" else $"Mapping for {tm.Tag} Tag")
|
||||||
|
|> withAntiCsrf ctx
|
||||||
|
|> addToHash ViewContext.Model (EditTagMapModel.fromMapping tm)
|
||||||
|
|> adminBareView "tag-mapping-edit" next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/settings/tag-mapping/save
|
||||||
|
let save : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||||
|
let data = ctx.Data
|
||||||
|
let! model = ctx.BindFormAsync<EditTagMapModel> ()
|
||||||
|
let tagMap =
|
||||||
|
if model.IsNew then someTask { TagMap.empty with Id = TagMapId.create (); WebLogId = ctx.WebLog.Id }
|
||||||
|
else data.TagMap.FindById (TagMapId model.Id) ctx.WebLog.Id
|
||||||
|
match! tagMap with
|
||||||
|
| Some tm ->
|
||||||
|
do! data.TagMap.Save { tm with Tag = model.Tag.ToLower (); UrlValue = model.UrlValue.ToLower () }
|
||||||
|
do! addMessage ctx { UserMessage.success with Message = "Tag mapping saved successfully" }
|
||||||
|
return! all next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/settings/tag-mapping/{id}/delete
|
||||||
|
let delete tagMapId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||||
|
match! ctx.Data.TagMap.Delete (TagMapId tagMapId) ctx.WebLog.Id with
|
||||||
|
| true -> do! addMessage ctx { UserMessage.success with Message = "Tag mapping deleted successfully" }
|
||||||
|
| false -> do! addMessage ctx { UserMessage.error with Message = "Tag mapping not found; nothing deleted" }
|
||||||
|
return! all next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// ~~ THEMES ~~
|
||||||
|
module Theme =
|
||||||
|
|
||||||
|
open System
|
||||||
|
open System.IO
|
||||||
|
open System.IO.Compression
|
||||||
|
open System.Text.RegularExpressions
|
||||||
|
open MyWebLog.Data
|
||||||
|
|
||||||
|
// GET /admin/theme/list
|
||||||
|
let all : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
|
||||||
|
let! themes = ctx.Data.Theme.All ()
|
||||||
|
return!
|
||||||
|
hashForPage "Themes"
|
||||||
|
|> withAntiCsrf ctx
|
||||||
|
|> addToHash "themes" (themes |> List.map (DisplayTheme.fromTheme WebLogCache.isThemeInUse) |> Array.ofList)
|
||||||
|
|> adminBareView "theme-list-body" next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/theme/new
|
||||||
|
let add : HttpHandler = requireAccess Administrator >=> fun next ctx ->
|
||||||
|
hashForPage "Upload a Theme File"
|
||||||
|
|> withAntiCsrf ctx
|
||||||
|
|> adminBareView "theme-upload" next ctx
|
||||||
|
|
||||||
|
/// Update the name and version for a theme based on the version.txt file, if present
|
||||||
|
let private updateNameAndVersion (theme : Theme) (zip : ZipArchive) = backgroundTask {
|
||||||
|
let now () = DateTime.UtcNow.ToString "yyyyMMdd.HHmm"
|
||||||
|
match zip.Entries |> Seq.filter (fun it -> it.FullName = "version.txt") |> Seq.tryHead with
|
||||||
|
| Some versionItem ->
|
||||||
|
use versionFile = new StreamReader(versionItem.Open ())
|
||||||
|
let! versionText = versionFile.ReadToEndAsync ()
|
||||||
|
let parts = versionText.Trim().Replace("\r", "").Split "\n"
|
||||||
|
let displayName = if parts[0] > "" then parts[0] else ThemeId.toString theme.Id
|
||||||
|
let version = if parts.Length > 1 && parts[1] > "" then parts[1] else now ()
|
||||||
|
return { theme with Name = displayName; Version = version }
|
||||||
|
| None -> return { theme with Name = ThemeId.toString theme.Id; Version = now () }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the theme with all templates from the ZIP archive
|
||||||
|
let private updateTemplates (theme : Theme) (zip : ZipArchive) = backgroundTask {
|
||||||
|
let tasks =
|
||||||
|
zip.Entries
|
||||||
|
|> Seq.filter (fun it -> it.Name.EndsWith ".liquid")
|
||||||
|
|> Seq.map (fun templateItem -> backgroundTask {
|
||||||
|
use templateFile = new StreamReader (templateItem.Open ())
|
||||||
|
let! template = templateFile.ReadToEndAsync ()
|
||||||
|
return { Name = templateItem.Name.Replace (".liquid", ""); Text = template }
|
||||||
|
})
|
||||||
|
let! templates = Task.WhenAll tasks
|
||||||
|
return
|
||||||
|
templates
|
||||||
|
|> Array.fold (fun t template ->
|
||||||
|
{ t with Templates = template :: (t.Templates |> List.filter (fun it -> it.Name <> template.Name)) })
|
||||||
|
theme
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update theme assets from the ZIP archive
|
||||||
|
let private updateAssets themeId (zip : ZipArchive) (data : IData) = backgroundTask {
|
||||||
|
for asset in zip.Entries |> Seq.filter (fun it -> it.FullName.StartsWith "wwwroot") do
|
||||||
|
let assetName = asset.FullName.Replace ("wwwroot/", "")
|
||||||
|
if assetName <> "" && not (assetName.EndsWith "/") then
|
||||||
|
use stream = new MemoryStream ()
|
||||||
|
do! asset.Open().CopyToAsync stream
|
||||||
|
do! data.ThemeAsset.Save
|
||||||
|
{ Id = ThemeAssetId (themeId, assetName)
|
||||||
|
UpdatedOn = LocalDateTime.FromDateTime(asset.LastWriteTime.DateTime)
|
||||||
|
.InZoneLeniently(DateTimeZone.Utc).ToInstant ()
|
||||||
|
Data = stream.ToArray ()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive the theme ID from the file name given
|
||||||
|
let deriveIdFromFileName (fileName : string) =
|
||||||
|
let themeName = fileName.Split(".").[0].ToLowerInvariant().Replace (" ", "-")
|
||||||
|
if themeName.EndsWith "-theme" then
|
||||||
|
if Regex.IsMatch (themeName, """^[a-z0-9\-]+$""") then
|
||||||
|
Ok (ThemeId (themeName.Substring (0, themeName.Length - 6)))
|
||||||
|
else Error $"Theme ID {fileName} is invalid"
|
||||||
|
else Error "Theme .zip file name must end in \"-theme.zip\""
|
||||||
|
|
||||||
|
/// Load a theme from the given stream, which should contain a ZIP archive
|
||||||
|
let loadFromZip themeId file (data : IData) = backgroundTask {
|
||||||
|
let! isNew, theme = backgroundTask {
|
||||||
|
match! data.Theme.FindById themeId with
|
||||||
|
| Some t -> return false, t
|
||||||
|
| None -> return true, { Theme.empty with Id = themeId }
|
||||||
|
}
|
||||||
|
use zip = new ZipArchive (file, ZipArchiveMode.Read)
|
||||||
|
let! theme = updateNameAndVersion theme zip
|
||||||
|
if not isNew then do! data.ThemeAsset.DeleteByTheme theme.Id
|
||||||
|
let! theme = updateTemplates { theme with Templates = [] } zip
|
||||||
|
do! data.Theme.Save theme
|
||||||
|
do! updateAssets themeId zip data
|
||||||
|
|
||||||
|
return theme
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/theme/new
|
||||||
|
let save : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
|
||||||
|
if ctx.Request.HasFormContentType && ctx.Request.Form.Files.Count > 0 then
|
||||||
|
let themeFile = Seq.head ctx.Request.Form.Files
|
||||||
|
match deriveIdFromFileName themeFile.FileName with
|
||||||
|
| Ok themeId when themeId <> adminTheme ->
|
||||||
|
let data = ctx.Data
|
||||||
|
let! exists = data.Theme.Exists themeId
|
||||||
|
let isNew = not exists
|
||||||
|
let! model = ctx.BindFormAsync<UploadThemeModel> ()
|
||||||
|
if isNew || model.DoOverwrite then
|
||||||
|
// Load the theme to the database
|
||||||
|
use stream = new MemoryStream ()
|
||||||
|
do! themeFile.CopyToAsync stream
|
||||||
|
let! _ = loadFromZip themeId stream data
|
||||||
|
do! ThemeAssetCache.refreshTheme themeId data
|
||||||
|
TemplateCache.invalidateTheme themeId
|
||||||
|
// Save the .zip file
|
||||||
|
use file = new FileStream ($"{ThemeId.toString themeId}-theme.zip", FileMode.Create)
|
||||||
|
do! themeFile.CopyToAsync file
|
||||||
|
do! addMessage ctx
|
||||||
|
{ UserMessage.success with
|
||||||
|
Message = $"""Theme {if isNew then "add" else "updat"}ed successfully"""
|
||||||
|
}
|
||||||
|
return! toAdminDashboard next ctx
|
||||||
|
else
|
||||||
|
do! addMessage ctx
|
||||||
|
{ UserMessage.error with
|
||||||
|
Message = "Theme exists and overwriting was not requested; nothing saved"
|
||||||
|
}
|
||||||
|
return! toAdminDashboard next ctx
|
||||||
|
| Ok _ ->
|
||||||
|
do! addMessage ctx { UserMessage.error with Message = "You may not replace the admin theme" }
|
||||||
|
return! toAdminDashboard next ctx
|
||||||
|
| Error message ->
|
||||||
|
do! addMessage ctx { UserMessage.error with Message = message }
|
||||||
|
return! toAdminDashboard next ctx
|
||||||
|
else return! RequestErrors.BAD_REQUEST "Bad request" next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/theme/{id}/delete
|
||||||
|
let delete themeId : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
|
||||||
|
let data = ctx.Data
|
||||||
|
match themeId with
|
||||||
|
| "admin" | "default" ->
|
||||||
|
do! addMessage ctx { UserMessage.error with Message = $"You may not delete the {themeId} theme" }
|
||||||
|
return! all next ctx
|
||||||
|
| it when WebLogCache.isThemeInUse (ThemeId it) ->
|
||||||
|
do! addMessage ctx
|
||||||
|
{ UserMessage.error with
|
||||||
|
Message = $"You may not delete the {themeId} theme, as it is currently in use"
|
||||||
|
}
|
||||||
|
return! all next ctx
|
||||||
|
| _ ->
|
||||||
|
match! data.Theme.Delete (ThemeId themeId) with
|
||||||
|
| true ->
|
||||||
|
let zippedTheme = $"{themeId}-theme.zip"
|
||||||
|
if File.Exists zippedTheme then File.Delete zippedTheme
|
||||||
|
do! addMessage ctx { UserMessage.success with Message = $"Theme ID {themeId} deleted successfully" }
|
||||||
|
return! all next ctx
|
||||||
|
| false -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// ~~ WEB LOG SETTINGS ~~
|
||||||
|
module WebLog =
|
||||||
|
|
||||||
|
open System.Collections.Generic
|
||||||
|
open System.IO
|
||||||
|
|
||||||
|
// GET /admin/settings
|
||||||
|
let settings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||||
|
let data = ctx.Data
|
||||||
|
match! TemplateCache.get adminTheme "user-list-body" data with
|
||||||
|
| Ok userTemplate ->
|
||||||
|
match! TemplateCache.get adminTheme "tag-mapping-list-body" ctx.Data with
|
||||||
|
| Ok tagMapTemplate ->
|
||||||
|
let! allPages = data.Page.All ctx.WebLog.Id
|
||||||
|
let! themes = data.Theme.All ()
|
||||||
|
let! users = data.WebLogUser.FindByWebLog ctx.WebLog.Id
|
||||||
|
let! hash =
|
||||||
|
hashForPage "Web Log Settings"
|
||||||
|
|> withAntiCsrf ctx
|
||||||
|
|> addToHash ViewContext.Model (SettingsModel.fromWebLog ctx.WebLog)
|
||||||
|
|> addToHash "pages" (
|
||||||
|
seq {
|
||||||
|
KeyValuePair.Create ("posts", "- First Page of Posts -")
|
||||||
|
yield! allPages
|
||||||
|
|> List.sortBy (fun p -> p.Title.ToLower ())
|
||||||
|
|> List.map (fun p -> KeyValuePair.Create (PageId.toString p.Id, p.Title))
|
||||||
|
}
|
||||||
|
|> Array.ofSeq)
|
||||||
|
|> addToHash "themes" (
|
||||||
|
themes
|
||||||
|
|> Seq.ofList
|
||||||
|
|> Seq.map (fun it ->
|
||||||
|
KeyValuePair.Create (ThemeId.toString it.Id, $"{it.Name} (v{it.Version})"))
|
||||||
|
|> Array.ofSeq)
|
||||||
|
|> addToHash "upload_values" [|
|
||||||
|
KeyValuePair.Create (UploadDestination.toString Database, "Database")
|
||||||
|
KeyValuePair.Create (UploadDestination.toString Disk, "Disk")
|
||||||
|
|]
|
||||||
|
|> addToHash "users" (users |> List.map (DisplayUser.fromUser ctx.WebLog) |> Array.ofList)
|
||||||
|
|> addToHash "rss_model" (EditRssModel.fromRssOptions ctx.WebLog.Rss)
|
||||||
|
|> addToHash "custom_feeds" (
|
||||||
|
ctx.WebLog.Rss.CustomFeeds
|
||||||
|
|> List.map (DisplayCustomFeed.fromFeed (CategoryCache.get ctx))
|
||||||
|
|> Array.ofList)
|
||||||
|
|> addViewContext ctx
|
||||||
|
let! hash' = TagMapping.withTagMappings ctx hash
|
||||||
|
return!
|
||||||
|
addToHash "user_list" (userTemplate.Render hash') hash'
|
||||||
|
|> addToHash "tag_mapping_list" (tagMapTemplate.Render hash')
|
||||||
|
|> adminView "settings" next ctx
|
||||||
|
| Error message -> return! Error.server message next ctx
|
||||||
|
| Error message -> return! Error.server message next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/settings
|
||||||
|
let saveSettings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||||
|
let data = ctx.Data
|
||||||
|
let! model = ctx.BindFormAsync<SettingsModel> ()
|
||||||
|
match! data.WebLog.FindById ctx.WebLog.Id with
|
||||||
|
| Some webLog ->
|
||||||
|
let oldSlug = webLog.Slug
|
||||||
|
let webLog = model.update webLog
|
||||||
|
do! data.WebLog.UpdateSettings webLog
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
WebLogCache.set webLog
|
||||||
|
|
||||||
|
if oldSlug <> webLog.Slug then
|
||||||
|
// Rename disk directory if it exists
|
||||||
|
let uploadRoot = Path.Combine ("wwwroot", "upload")
|
||||||
|
let oldDir = Path.Combine (uploadRoot, oldSlug)
|
||||||
|
if Directory.Exists oldDir then Directory.Move (oldDir, Path.Combine (uploadRoot, webLog.Slug))
|
||||||
|
|
||||||
|
do! addMessage ctx { UserMessage.success with Message = "Web log settings saved successfully" }
|
||||||
|
return! redirectToGet "admin/settings" next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,8 +95,8 @@ let private toFeedItem webLog (authors : MetaItem list) (cats : DisplayCategory[
|
|||||||
let item = SyndicationItem (
|
let item = SyndicationItem (
|
||||||
Id = WebLog.absoluteUrl webLog post.Permalink,
|
Id = WebLog.absoluteUrl webLog post.Permalink,
|
||||||
Title = TextSyndicationContent.CreateHtmlContent post.Title,
|
Title = TextSyndicationContent.CreateHtmlContent post.Title,
|
||||||
PublishDate = DateTimeOffset post.PublishedOn.Value,
|
PublishDate = post.PublishedOn.Value.ToDateTimeOffset (),
|
||||||
LastUpdatedTime = DateTimeOffset post.UpdatedOn,
|
LastUpdatedTime = post.UpdatedOn.ToDateTimeOffset (),
|
||||||
Content = TextSyndicationContent.CreatePlaintextContent plainText)
|
Content = TextSyndicationContent.CreatePlaintextContent plainText)
|
||||||
item.AddPermalink (Uri item.Id)
|
item.AddPermalink (Uri item.Id)
|
||||||
|
|
||||||
@@ -163,8 +163,8 @@ let private addEpisode webLog (podcast : PodcastOptions) (episode : Episode) (po
|
|||||||
item.ElementExtensions.Add ("author", Namespace.iTunes, podcast.DisplayedAuthor)
|
item.ElementExtensions.Add ("author", Namespace.iTunes, podcast.DisplayedAuthor)
|
||||||
item.ElementExtensions.Add ("explicit", Namespace.iTunes, epExplicit)
|
item.ElementExtensions.Add ("explicit", Namespace.iTunes, epExplicit)
|
||||||
episode.Subtitle |> Option.iter (fun it -> item.ElementExtensions.Add ("subtitle", Namespace.iTunes, it))
|
episode.Subtitle |> Option.iter (fun it -> item.ElementExtensions.Add ("subtitle", Namespace.iTunes, it))
|
||||||
episode.Duration
|
Episode.formatDuration episode
|
||||||
|> Option.iter (fun it -> item.ElementExtensions.Add ("duration", Namespace.iTunes, it.ToString """hh\:mm\:ss"""))
|
|> Option.iter (fun it -> item.ElementExtensions.Add ("duration", Namespace.iTunes, it))
|
||||||
|
|
||||||
match episode.ChapterFile with
|
match episode.ChapterFile with
|
||||||
| Some chapters ->
|
| Some chapters ->
|
||||||
@@ -381,7 +381,7 @@ let createFeed (feedType : FeedType) posts : HttpHandler = fun next ctx -> backg
|
|||||||
addNamespace feed "content" Namespace.content
|
addNamespace feed "content" Namespace.content
|
||||||
setTitleAndDescription feedType webLog cats feed
|
setTitleAndDescription feedType webLog cats feed
|
||||||
|
|
||||||
feed.LastUpdatedTime <- (List.head posts).UpdatedOn |> DateTimeOffset
|
feed.LastUpdatedTime <- (List.head posts).UpdatedOn.ToDateTimeOffset ()
|
||||||
feed.Generator <- ctx.Generator
|
feed.Generator <- ctx.Generator
|
||||||
feed.Items <- posts |> Seq.ofList |> Seq.map toItem
|
feed.Items <- posts |> Seq.ofList |> Seq.map toItem
|
||||||
feed.Language <- "en"
|
feed.Language <- "en"
|
||||||
@@ -414,17 +414,6 @@ let generate (feedType : FeedType) postCount : HttpHandler = fun next ctx -> bac
|
|||||||
|
|
||||||
// ~~ FEED ADMINISTRATION ~~
|
// ~~ FEED ADMINISTRATION ~~
|
||||||
|
|
||||||
// GET /admin/settings/rss
|
|
||||||
let editSettings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx ->
|
|
||||||
hashForPage "RSS Settings"
|
|
||||||
|> withAntiCsrf ctx
|
|
||||||
|> addToHash ViewContext.Model (EditRssModel.fromRssOptions ctx.WebLog.Rss)
|
|
||||||
|> addToHash "custom_feeds" (
|
|
||||||
ctx.WebLog.Rss.CustomFeeds
|
|
||||||
|> List.map (DisplayCustomFeed.fromFeed (CategoryCache.get ctx))
|
|
||||||
|> Array.ofList)
|
|
||||||
|> adminView "rss-settings" next ctx
|
|
||||||
|
|
||||||
// POST /admin/settings/rss
|
// POST /admin/settings/rss
|
||||||
let saveSettings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
let saveSettings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||||
let data = ctx.Data
|
let data = ctx.Data
|
||||||
@@ -435,7 +424,7 @@ let saveSettings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> t
|
|||||||
do! data.WebLog.UpdateRssOptions webLog
|
do! data.WebLog.UpdateRssOptions webLog
|
||||||
WebLogCache.set webLog
|
WebLogCache.set webLog
|
||||||
do! addMessage ctx { UserMessage.success with Message = "RSS settings updated successfully" }
|
do! addMessage ctx { UserMessage.success with Message = "RSS settings updated successfully" }
|
||||||
return! redirectToGet "admin/settings/rss" next ctx
|
return! redirectToGet "admin/settings#rss-settings" next ctx
|
||||||
| None -> return! Error.notFound next ctx
|
| None -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,6 +496,6 @@ let deleteCustomFeed feedId : HttpHandler = requireAccess WebLogAdmin >=> fun ne
|
|||||||
do! addMessage ctx { UserMessage.success with Message = "Custom feed deleted successfully" }
|
do! addMessage ctx { UserMessage.success with Message = "Custom feed deleted successfully" }
|
||||||
else
|
else
|
||||||
do! addMessage ctx { UserMessage.warning with Message = "Custom feed not found; no action taken" }
|
do! addMessage ctx { UserMessage.warning with Message = "Custom feed not found; no action taken" }
|
||||||
return! redirectToGet "admin/settings/rss" next ctx
|
return! redirectToGet "admin/settings#rss-settings" next ctx
|
||||||
| None -> return! Error.notFound next ctx
|
| None -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,23 +218,6 @@ let addViewContext ctx (hash : Hash) = task {
|
|||||||
let isHtmx (ctx : HttpContext) =
|
let isHtmx (ctx : HttpContext) =
|
||||||
ctx.Request.IsHtmx && not ctx.Request.IsHtmxRefresh
|
ctx.Request.IsHtmx && not ctx.Request.IsHtmxRefresh
|
||||||
|
|
||||||
/// Render a view for the specified theme, using the specified template, layout, and hash
|
|
||||||
let viewForTheme themeId template next ctx (hash : Hash) = task {
|
|
||||||
let! hash = addViewContext ctx hash
|
|
||||||
let (ThemeId theme) = themeId
|
|
||||||
// NOTE: DotLiquid does not support {% render %} or {% include %} in its templates, so we will do a 2-pass render;
|
|
||||||
// the net effect is a "layout" capability similar to Razor or Pug
|
|
||||||
|
|
||||||
// Render view content...
|
|
||||||
let! contentTemplate = TemplateCache.get theme template ctx.Data
|
|
||||||
let _ = addToHash ViewContext.Content (contentTemplate.Render hash) hash
|
|
||||||
|
|
||||||
// ...then render that content with its layout
|
|
||||||
let! layoutTemplate = TemplateCache.get theme (if isHtmx ctx then "layout-partial" else "layout") ctx.Data
|
|
||||||
|
|
||||||
return! htmlString (layoutTemplate.Render hash) next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert messages to headers (used for htmx responses)
|
/// Convert messages to headers (used for htmx responses)
|
||||||
let messagesToHeaders (messages : UserMessage array) : HttpHandler =
|
let messagesToHeaders (messages : UserMessage array) : HttpHandler =
|
||||||
seq {
|
seq {
|
||||||
@@ -249,50 +232,12 @@ let messagesToHeaders (messages : UserMessage array) : HttpHandler =
|
|||||||
}
|
}
|
||||||
|> Seq.reduce (>=>)
|
|> Seq.reduce (>=>)
|
||||||
|
|
||||||
/// Render a bare view for the specified theme, using the specified template and hash
|
|
||||||
let bareForTheme themeId template next ctx (hash : Hash) = task {
|
|
||||||
let! hash = addViewContext ctx hash
|
|
||||||
let (ThemeId theme) = themeId
|
|
||||||
|
|
||||||
if not (hash.ContainsKey ViewContext.Content) then
|
|
||||||
let! contentTemplate = TemplateCache.get theme template ctx.Data
|
|
||||||
addToHash ViewContext.Content (contentTemplate.Render hash) hash |> ignore
|
|
||||||
|
|
||||||
// Bare templates are rendered with layout-bare
|
|
||||||
let! layoutTemplate = TemplateCache.get theme "layout-bare" ctx.Data
|
|
||||||
return!
|
|
||||||
(messagesToHeaders (hash[ViewContext.Messages] :?> UserMessage[])
|
|
||||||
>=> htmlString (layoutTemplate.Render hash))
|
|
||||||
next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return a view for the web log's default theme
|
|
||||||
let themedView template next ctx hash = task {
|
|
||||||
let! hash = addViewContext ctx hash
|
|
||||||
return! viewForTheme (hash[ViewContext.WebLog] :?> WebLog).ThemeId template next ctx hash
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Display a view for the admin theme
|
|
||||||
let adminView template =
|
|
||||||
viewForTheme (ThemeId "admin") template
|
|
||||||
|
|
||||||
/// Display a bare view for the admin theme
|
|
||||||
let adminBareView template =
|
|
||||||
bareForTheme (ThemeId "admin") template
|
|
||||||
|
|
||||||
/// Redirect after doing some action; commits session and issues a temporary redirect
|
/// Redirect after doing some action; commits session and issues a temporary redirect
|
||||||
let redirectToGet url : HttpHandler = fun _ ctx -> task {
|
let redirectToGet url : HttpHandler = fun _ ctx -> task {
|
||||||
do! commitSession ctx
|
do! commitSession ctx
|
||||||
return! redirectTo false (WebLog.relativeUrl ctx.WebLog (Permalink url)) earlyReturn ctx
|
return! redirectTo false (WebLog.relativeUrl ctx.WebLog (Permalink url)) earlyReturn ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate the anti cross-site request forgery token in the current request
|
|
||||||
let validateCsrf : HttpHandler = fun next ctx -> task {
|
|
||||||
match! ctx.AntiForgery.IsRequestValidAsync ctx with
|
|
||||||
| true -> return! next ctx
|
|
||||||
| false -> return! RequestErrors.BAD_REQUEST "CSRF token invalid" earlyReturn ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Handlers for error conditions
|
/// Handlers for error conditions
|
||||||
module Error =
|
module Error =
|
||||||
@@ -322,11 +267,82 @@ module Error =
|
|||||||
let messages = [|
|
let messages = [|
|
||||||
{ UserMessage.error with Message = $"The URL {ctx.Request.Path.Value} was not found" }
|
{ UserMessage.error with Message = $"The URL {ctx.Request.Path.Value} was not found" }
|
||||||
|]
|
|]
|
||||||
(messagesToHeaders messages >=> setStatusCode 404) earlyReturn ctx
|
RequestErrors.notFound (messagesToHeaders messages) earlyReturn ctx
|
||||||
else
|
else RequestErrors.NOT_FOUND "Not found" earlyReturn ctx)
|
||||||
(setStatusCode 404 >=> text "Not found") earlyReturn ctx)
|
|
||||||
|
let server message : HttpHandler =
|
||||||
|
handleContext (fun ctx ->
|
||||||
|
if isHtmx ctx then
|
||||||
|
let messages = [| { UserMessage.error with Message = message } |]
|
||||||
|
ServerErrors.internalError (messagesToHeaders messages) earlyReturn ctx
|
||||||
|
else ServerErrors.INTERNAL_ERROR message earlyReturn ctx)
|
||||||
|
|
||||||
|
|
||||||
|
/// Render a view for the specified theme, using the specified template, layout, and hash
|
||||||
|
let viewForTheme themeId template next ctx (hash : Hash) = task {
|
||||||
|
let! hash = addViewContext ctx hash
|
||||||
|
|
||||||
|
// NOTE: DotLiquid does not support {% render %} or {% include %} in its templates, so we will do a 2-pass render;
|
||||||
|
// the net effect is a "layout" capability similar to Razor or Pug
|
||||||
|
|
||||||
|
// Render view content...
|
||||||
|
match! TemplateCache.get themeId template ctx.Data with
|
||||||
|
| Ok contentTemplate ->
|
||||||
|
let _ = addToHash ViewContext.Content (contentTemplate.Render hash) hash
|
||||||
|
// ...then render that content with its layout
|
||||||
|
match! TemplateCache.get themeId (if isHtmx ctx then "layout-partial" else "layout") ctx.Data with
|
||||||
|
| Ok layoutTemplate -> return! htmlString (layoutTemplate.Render hash) next ctx
|
||||||
|
| Error message -> return! Error.server message next ctx
|
||||||
|
| Error message -> return! Error.server message next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a bare view for the specified theme, using the specified template and hash
|
||||||
|
let bareForTheme themeId template next ctx (hash : Hash) = task {
|
||||||
|
let! hash = addViewContext ctx hash
|
||||||
|
let withContent = task {
|
||||||
|
if hash.ContainsKey ViewContext.Content then return Ok hash
|
||||||
|
else
|
||||||
|
match! TemplateCache.get themeId template ctx.Data with
|
||||||
|
| Ok contentTemplate -> return Ok (addToHash ViewContext.Content (contentTemplate.Render hash) hash)
|
||||||
|
| Error message -> return Error message
|
||||||
|
}
|
||||||
|
match! withContent with
|
||||||
|
| Ok completeHash ->
|
||||||
|
// Bare templates are rendered with layout-bare
|
||||||
|
match! TemplateCache.get themeId "layout-bare" ctx.Data with
|
||||||
|
| Ok layoutTemplate ->
|
||||||
|
return!
|
||||||
|
(messagesToHeaders (hash[ViewContext.Messages] :?> UserMessage[])
|
||||||
|
>=> htmlString (layoutTemplate.Render completeHash))
|
||||||
|
next ctx
|
||||||
|
| Error message -> return! Error.server message next ctx
|
||||||
|
| Error message -> return! Error.server message next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a view for the web log's default theme
|
||||||
|
let themedView template next ctx hash = task {
|
||||||
|
let! hash = addViewContext ctx hash
|
||||||
|
return! viewForTheme (hash[ViewContext.WebLog] :?> WebLog).ThemeId template next ctx hash
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ID for the admin theme
|
||||||
|
let adminTheme = ThemeId "admin"
|
||||||
|
|
||||||
|
/// Display a view for the admin theme
|
||||||
|
let adminView template =
|
||||||
|
viewForTheme adminTheme template
|
||||||
|
|
||||||
|
/// Display a bare view for the admin theme
|
||||||
|
let adminBareView template =
|
||||||
|
bareForTheme adminTheme template
|
||||||
|
|
||||||
|
/// Validate the anti cross-site request forgery token in the current request
|
||||||
|
let validateCsrf : HttpHandler = fun next ctx -> task {
|
||||||
|
match! ctx.AntiForgery.IsRequestValidAsync ctx with
|
||||||
|
| true -> return! next ctx
|
||||||
|
| false -> return! RequestErrors.BAD_REQUEST "CSRF token invalid" earlyReturn ctx
|
||||||
|
}
|
||||||
|
|
||||||
/// Require a user to be logged on
|
/// Require a user to be logged on
|
||||||
let requireUser : HttpHandler = requiresAuthentication Error.notAuthorized
|
let requireUser : HttpHandler = requiresAuthentication Error.notAuthorized
|
||||||
|
|
||||||
@@ -403,10 +419,11 @@ let getCategoryIds slug ctx =
|
|||||||
|
|
||||||
open System
|
open System
|
||||||
open System.Globalization
|
open System.Globalization
|
||||||
|
open NodaTime
|
||||||
|
|
||||||
/// Parse a date/time to UTC
|
/// Parse a date/time to UTC
|
||||||
let parseToUtc (date : string) =
|
let parseToUtc (date : string) =
|
||||||
DateTime.Parse (date, null, DateTimeStyles.AdjustToUniversal)
|
Instant.FromDateTimeUtc (DateTime.Parse (date, null, DateTimeStyles.AdjustToUniversal))
|
||||||
|
|
||||||
open Microsoft.Extensions.DependencyInjection
|
open Microsoft.Extensions.DependencyInjection
|
||||||
open Microsoft.Extensions.Logging
|
open Microsoft.Extensions.Logging
|
||||||
|
|||||||
@@ -124,8 +124,14 @@ let private findPageRevision pgId revDate (ctx : HttpContext) = task {
|
|||||||
let previewRevision (pgId, revDate) : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
let previewRevision (pgId, revDate) : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
||||||
match! findPageRevision pgId revDate ctx with
|
match! findPageRevision pgId revDate ctx with
|
||||||
| Some pg, Some rev when canEdit pg.AuthorId ctx ->
|
| Some pg, Some rev when canEdit pg.AuthorId ctx ->
|
||||||
|
let _, extra = WebLog.hostAndPath ctx.WebLog
|
||||||
return! {|
|
return! {|
|
||||||
content = $"""<div class="mwl-revision-preview mb-3">{MarkupText.toHtml rev.Text}</div>"""
|
content =
|
||||||
|
[ """<div class="mwl-revision-preview mb-3">"""
|
||||||
|
(MarkupText.toHtml >> addBaseToRelativeUrls extra) rev.Text
|
||||||
|
"</div>"
|
||||||
|
]
|
||||||
|
|> String.concat ""
|
||||||
|}
|
|}
|
||||||
|> makeHash |> adminBareView "" next ctx
|
|> makeHash |> adminBareView "" next ctx
|
||||||
| Some _, Some _ -> return! Error.notAuthorized next ctx
|
| Some _, Some _ -> return! Error.notAuthorized next ctx
|
||||||
@@ -133,15 +139,13 @@ let previewRevision (pgId, revDate) : HttpHandler = requireAccess Author >=> fun
|
|||||||
| _, None -> return! Error.notFound next ctx
|
| _, None -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
open System
|
|
||||||
|
|
||||||
// POST /admin/page/{id}/revision/{revision-date}/restore
|
// POST /admin/page/{id}/revision/{revision-date}/restore
|
||||||
let restoreRevision (pgId, revDate) : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
let restoreRevision (pgId, revDate) : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
||||||
match! findPageRevision pgId revDate ctx with
|
match! findPageRevision pgId revDate ctx with
|
||||||
| Some pg, Some rev when canEdit pg.AuthorId ctx ->
|
| Some pg, Some rev when canEdit pg.AuthorId ctx ->
|
||||||
do! ctx.Data.Page.Update
|
do! ctx.Data.Page.Update
|
||||||
{ pg with
|
{ pg with
|
||||||
Revisions = { rev with AsOf = DateTime.UtcNow }
|
Revisions = { rev with AsOf = Noda.now () }
|
||||||
:: (pg.Revisions |> List.filter (fun r -> r.AsOf <> rev.AsOf))
|
:: (pg.Revisions |> List.filter (fun r -> r.AsOf <> rev.AsOf))
|
||||||
}
|
}
|
||||||
do! addMessage ctx { UserMessage.success with Message = "Revision restored successfully" }
|
do! addMessage ctx { UserMessage.success with Message = "Revision restored successfully" }
|
||||||
@@ -167,7 +171,7 @@ let deleteRevision (pgId, revDate) : HttpHandler = requireAccess Author >=> fun
|
|||||||
let save : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
let save : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
||||||
let! model = ctx.BindFormAsync<EditPageModel> ()
|
let! model = ctx.BindFormAsync<EditPageModel> ()
|
||||||
let data = ctx.Data
|
let data = ctx.Data
|
||||||
let now = DateTime.UtcNow
|
let now = Noda.now ()
|
||||||
let tryPage =
|
let tryPage =
|
||||||
if model.IsNew then
|
if model.IsNew then
|
||||||
{ Page.empty with
|
{ Page.empty with
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ let preparePostList webLog posts listType (url : string) pageNbr perPage (data :
|
|||||||
let! olderPost, newerPost =
|
let! olderPost, newerPost =
|
||||||
match listType with
|
match listType with
|
||||||
| SinglePost ->
|
| SinglePost ->
|
||||||
let post = List.head posts
|
let post = List.head posts
|
||||||
let dateTime = defaultArg post.PublishedOn post.UpdatedOn
|
let target = defaultArg post.PublishedOn post.UpdatedOn
|
||||||
data.Post.FindSurroundingPosts webLog.Id dateTime
|
data.Post.FindSurroundingPosts webLog.Id target
|
||||||
| _ -> Task.FromResult (None, None)
|
| _ -> Task.FromResult (None, None)
|
||||||
let newerLink =
|
let newerLink =
|
||||||
match listType, pageNbr with
|
match listType, pageNbr with
|
||||||
@@ -329,8 +329,14 @@ let private findPostRevision postId revDate (ctx : HttpContext) = task {
|
|||||||
let previewRevision (postId, revDate) : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
let previewRevision (postId, revDate) : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
||||||
match! findPostRevision postId revDate ctx with
|
match! findPostRevision postId revDate ctx with
|
||||||
| Some post, Some rev when canEdit post.AuthorId ctx ->
|
| Some post, Some rev when canEdit post.AuthorId ctx ->
|
||||||
|
let _, extra = WebLog.hostAndPath ctx.WebLog
|
||||||
return! {|
|
return! {|
|
||||||
content = $"""<div class="mwl-revision-preview mb-3">{MarkupText.toHtml rev.Text}</div>"""
|
content =
|
||||||
|
[ """<div class="mwl-revision-preview mb-3">"""
|
||||||
|
(MarkupText.toHtml >> addBaseToRelativeUrls extra) rev.Text
|
||||||
|
"</div>"
|
||||||
|
]
|
||||||
|
|> String.concat ""
|
||||||
|}
|
|}
|
||||||
|> makeHash |> adminBareView "" next ctx
|
|> makeHash |> adminBareView "" next ctx
|
||||||
| Some _, Some _ -> return! Error.notAuthorized next ctx
|
| Some _, Some _ -> return! Error.notAuthorized next ctx
|
||||||
@@ -344,7 +350,7 @@ let restoreRevision (postId, revDate) : HttpHandler = requireAccess Author >=> f
|
|||||||
| Some post, Some rev when canEdit post.AuthorId ctx ->
|
| Some post, Some rev when canEdit post.AuthorId ctx ->
|
||||||
do! ctx.Data.Post.Update
|
do! ctx.Data.Post.Update
|
||||||
{ post with
|
{ post with
|
||||||
Revisions = { rev with AsOf = DateTime.UtcNow }
|
Revisions = { rev with AsOf = Noda.now () }
|
||||||
:: (post.Revisions |> List.filter (fun r -> r.AsOf <> rev.AsOf))
|
:: (post.Revisions |> List.filter (fun r -> r.AsOf <> rev.AsOf))
|
||||||
}
|
}
|
||||||
do! addMessage ctx { UserMessage.success with Message = "Revision restored successfully" }
|
do! addMessage ctx { UserMessage.success with Message = "Revision restored successfully" }
|
||||||
@@ -370,7 +376,6 @@ let deleteRevision (postId, revDate) : HttpHandler = requireAccess Author >=> fu
|
|||||||
let save : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
let save : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
||||||
let! model = ctx.BindFormAsync<EditPostModel> ()
|
let! model = ctx.BindFormAsync<EditPostModel> ()
|
||||||
let data = ctx.Data
|
let data = ctx.Data
|
||||||
let now = DateTime.UtcNow
|
|
||||||
let tryPost =
|
let tryPost =
|
||||||
if model.IsNew then
|
if model.IsNew then
|
||||||
{ Post.empty with
|
{ Post.empty with
|
||||||
@@ -383,7 +388,7 @@ let save : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
|||||||
| Some post when canEdit post.AuthorId ctx ->
|
| Some post when canEdit post.AuthorId ctx ->
|
||||||
let priorCats = post.CategoryIds
|
let priorCats = post.CategoryIds
|
||||||
let updatedPost =
|
let updatedPost =
|
||||||
model.UpdatePost post now
|
model.UpdatePost post (Noda.now ())
|
||||||
|> function
|
|> function
|
||||||
| post ->
|
| post ->
|
||||||
if model.SetPublished then
|
if model.SetPublished then
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ module Asset =
|
|||||||
| Some asset ->
|
| Some asset ->
|
||||||
match Upload.checkModified asset.UpdatedOn ctx with
|
match Upload.checkModified asset.UpdatedOn ctx with
|
||||||
| Some threeOhFour -> return! threeOhFour next ctx
|
| Some threeOhFour -> return! threeOhFour next ctx
|
||||||
| None -> return! Upload.sendFile asset.UpdatedOn path asset.Data next ctx
|
| None -> return! Upload.sendFile (asset.UpdatedOn.ToDateTimeUtc ()) path asset.Data next ctx
|
||||||
| None -> return! Error.notFound next ctx
|
| None -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,12 +106,14 @@ let router : HttpHandler = choose [
|
|||||||
]
|
]
|
||||||
subRoute "/admin" (requireUser >=> choose [
|
subRoute "/admin" (requireUser >=> choose [
|
||||||
GET_HEAD >=> choose [
|
GET_HEAD >=> choose [
|
||||||
|
route "/administration" >=> Admin.Dashboard.admin
|
||||||
subRoute "/categor" (choose [
|
subRoute "/categor" (choose [
|
||||||
route "ies" >=> Admin.listCategories
|
route "ies" >=> Admin.Category.all
|
||||||
route "ies/bare" >=> Admin.listCategoriesBare
|
route "ies/bare" >=> Admin.Category.bare
|
||||||
routef "y/%s/edit" Admin.editCategory
|
routef "y/%s/edit" Admin.Category.edit
|
||||||
])
|
])
|
||||||
route "/dashboard" >=> Admin.dashboard
|
route "/dashboard" >=> Admin.Dashboard.user
|
||||||
|
route "/my-info" >=> User.myInfo
|
||||||
subRoute "/page" (choose [
|
subRoute "/page" (choose [
|
||||||
route "s" >=> Page.all 1
|
route "s" >=> Page.all 1
|
||||||
routef "s/page/%i" Page.all
|
routef "s/page/%i" Page.all
|
||||||
@@ -129,34 +131,36 @@ let router : HttpHandler = choose [
|
|||||||
routef "/%s/revisions" Post.editRevisions
|
routef "/%s/revisions" Post.editRevisions
|
||||||
])
|
])
|
||||||
subRoute "/settings" (choose [
|
subRoute "/settings" (choose [
|
||||||
route "" >=> Admin.settings
|
route "" >=> Admin.WebLog.settings
|
||||||
subRoute "/rss" (choose [
|
routef "/rss/%s/edit" Feed.editCustomFeed
|
||||||
route "" >=> Feed.editSettings
|
subRoute "/user" (choose [
|
||||||
routef "/%s/edit" Feed.editCustomFeed
|
route "s" >=> User.all
|
||||||
|
routef "/%s/edit" User.edit
|
||||||
])
|
])
|
||||||
subRoute "/tag-mapping" (choose [
|
subRoute "/tag-mapping" (choose [
|
||||||
route "s" >=> Admin.tagMappings
|
route "s" >=> Admin.TagMapping.all
|
||||||
route "s/bare" >=> Admin.tagMappingsBare
|
routef "/%s/edit" Admin.TagMapping.edit
|
||||||
routef "/%s/edit" Admin.editMapping
|
|
||||||
])
|
])
|
||||||
])
|
])
|
||||||
route "/theme/update" >=> Admin.themeUpdatePage
|
subRoute "/theme" (choose [
|
||||||
|
route "/list" >=> Admin.Theme.all
|
||||||
|
route "/new" >=> Admin.Theme.add
|
||||||
|
])
|
||||||
subRoute "/upload" (choose [
|
subRoute "/upload" (choose [
|
||||||
route "s" >=> Upload.list
|
route "s" >=> Upload.list
|
||||||
route "/new" >=> Upload.showNew
|
route "/new" >=> Upload.showNew
|
||||||
])
|
])
|
||||||
subRoute "/user" (choose [
|
|
||||||
route "s" >=> User.all
|
|
||||||
route "s/bare" >=> User.bare
|
|
||||||
route "/my-info" >=> User.myInfo
|
|
||||||
routef "/%s/edit" User.edit
|
|
||||||
])
|
|
||||||
]
|
]
|
||||||
POST >=> validateCsrf >=> choose [
|
POST >=> validateCsrf >=> choose [
|
||||||
subRoute "/category" (choose [
|
subRoute "/cache" (choose [
|
||||||
route "/save" >=> Admin.saveCategory
|
routef "/theme/%s/refresh" Admin.Cache.refreshTheme
|
||||||
routef "/%s/delete" Admin.deleteCategory
|
routef "/web-log/%s/refresh" Admin.Cache.refreshWebLog
|
||||||
])
|
])
|
||||||
|
subRoute "/category" (choose [
|
||||||
|
route "/save" >=> Admin.Category.save
|
||||||
|
routef "/%s/delete" Admin.Category.delete
|
||||||
|
])
|
||||||
|
route "/my-info" >=> User.saveMyInfo
|
||||||
subRoute "/page" (choose [
|
subRoute "/page" (choose [
|
||||||
route "/save" >=> Page.save
|
route "/save" >=> Page.save
|
||||||
route "/permalinks" >=> Page.savePermalinks
|
route "/permalinks" >=> Page.savePermalinks
|
||||||
@@ -174,28 +178,30 @@ let router : HttpHandler = choose [
|
|||||||
routef "/%s/revisions/purge" Post.purgeRevisions
|
routef "/%s/revisions/purge" Post.purgeRevisions
|
||||||
])
|
])
|
||||||
subRoute "/settings" (choose [
|
subRoute "/settings" (choose [
|
||||||
route "" >=> Admin.saveSettings
|
route "" >=> Admin.WebLog.saveSettings
|
||||||
subRoute "/rss" (choose [
|
subRoute "/rss" (choose [
|
||||||
route "" >=> Feed.saveSettings
|
route "" >=> Feed.saveSettings
|
||||||
route "/save" >=> Feed.saveCustomFeed
|
route "/save" >=> Feed.saveCustomFeed
|
||||||
routef "/%s/delete" Feed.deleteCustomFeed
|
routef "/%s/delete" Feed.deleteCustomFeed
|
||||||
])
|
])
|
||||||
subRoute "/tag-mapping" (choose [
|
subRoute "/tag-mapping" (choose [
|
||||||
route "/save" >=> Admin.saveMapping
|
route "/save" >=> Admin.TagMapping.save
|
||||||
routef "/%s/delete" Admin.deleteMapping
|
routef "/%s/delete" Admin.TagMapping.delete
|
||||||
|
])
|
||||||
|
subRoute "/user" (choose [
|
||||||
|
route "/save" >=> User.save
|
||||||
|
routef "/%s/delete" User.delete
|
||||||
])
|
])
|
||||||
])
|
])
|
||||||
route "/theme/update" >=> Admin.updateTheme
|
subRoute "/theme" (choose [
|
||||||
|
route "/new" >=> Admin.Theme.save
|
||||||
|
routef "/%s/delete" Admin.Theme.delete
|
||||||
|
])
|
||||||
subRoute "/upload" (choose [
|
subRoute "/upload" (choose [
|
||||||
route "/save" >=> Upload.save
|
route "/save" >=> Upload.save
|
||||||
routexp "/delete/(.*)" Upload.deleteFromDisk
|
routexp "/delete/(.*)" Upload.deleteFromDisk
|
||||||
routef "/%s/delete" Upload.deleteFromDb
|
routef "/%s/delete" Upload.deleteFromDb
|
||||||
])
|
])
|
||||||
subRoute "/user" (choose [
|
|
||||||
route "/my-info" >=> User.saveMyInfo
|
|
||||||
route "/save" >=> User.save
|
|
||||||
routef "/%s/delete" User.delete
|
|
||||||
])
|
|
||||||
]
|
]
|
||||||
])
|
])
|
||||||
GET_HEAD >=> routexp "/category/(.*)" Post.pageOfCategorizedPosts
|
GET_HEAD >=> routexp "/category/(.*)" Post.pageOfCategorizedPosts
|
||||||
|
|||||||
@@ -29,15 +29,17 @@ module private Helpers =
|
|||||||
|
|
||||||
// ~~ SERVING UPLOADS ~~
|
// ~~ SERVING UPLOADS ~~
|
||||||
|
|
||||||
|
open System.Globalization
|
||||||
open Giraffe
|
open Giraffe
|
||||||
open Microsoft.AspNetCore.Http
|
open Microsoft.AspNetCore.Http
|
||||||
|
open NodaTime
|
||||||
|
|
||||||
/// Determine if the file has been modified since the date/time specified by the If-Modified-Since header
|
/// Determine if the file has been modified since the date/time specified by the If-Modified-Since header
|
||||||
let checkModified since (ctx : HttpContext) : HttpHandler option =
|
let checkModified since (ctx : HttpContext) : HttpHandler option =
|
||||||
match ctx.Request.Headers.IfModifiedSince with
|
match ctx.Request.Headers.IfModifiedSince with
|
||||||
| it when it.Count < 1 -> None
|
| it when it.Count < 1 -> None
|
||||||
| it when since > DateTime.Parse it[0] -> None
|
| it when since > Instant.FromDateTimeUtc (DateTime.Parse (it[0], null, DateTimeStyles.AdjustToUniversal)) -> None
|
||||||
| _ -> Some (setStatusCode 304 >=> setBodyFromString "Not Modified")
|
| _ -> Some (setStatusCode 304)
|
||||||
|
|
||||||
|
|
||||||
open Microsoft.AspNetCore.Http.Headers
|
open Microsoft.AspNetCore.Http.Headers
|
||||||
@@ -73,7 +75,7 @@ let serve (urlParts : string seq) : HttpHandler = fun next ctx -> task {
|
|||||||
| Some upload ->
|
| Some upload ->
|
||||||
match checkModified upload.UpdatedOn ctx with
|
match checkModified upload.UpdatedOn ctx with
|
||||||
| Some threeOhFour -> return! threeOhFour next ctx
|
| Some threeOhFour -> return! threeOhFour next ctx
|
||||||
| None -> return! sendFile upload.UpdatedOn path upload.Data next ctx
|
| None -> return! sendFile (upload.UpdatedOn.ToDateTimeUtc ()) path upload.Data next ctx
|
||||||
| None -> return! Error.notFound next ctx
|
| None -> return! Error.notFound next ctx
|
||||||
else
|
else
|
||||||
return! Error.notFound next ctx
|
return! Error.notFound next ctx
|
||||||
@@ -85,7 +87,7 @@ open System.Text.RegularExpressions
|
|||||||
open MyWebLog.ViewModels
|
open MyWebLog.ViewModels
|
||||||
|
|
||||||
/// Turn a string into a lowercase URL-safe slug
|
/// Turn a string into a lowercase URL-safe slug
|
||||||
let makeSlug it = ((Regex """\s+""").Replace ((Regex "[^A-z0-9 ]").Replace (it, ""), "-")).ToLowerInvariant ()
|
let makeSlug it = ((Regex """\s+""").Replace ((Regex "[^A-z0-9 -]").Replace (it, ""), "-")).ToLowerInvariant ()
|
||||||
|
|
||||||
// GET /admin/uploads
|
// GET /admin/uploads
|
||||||
let list : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
let list : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
||||||
@@ -143,7 +145,8 @@ let save : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
|||||||
let upload = Seq.head ctx.Request.Form.Files
|
let upload = Seq.head ctx.Request.Form.Files
|
||||||
let fileName = String.Concat (makeSlug (Path.GetFileNameWithoutExtension upload.FileName),
|
let fileName = String.Concat (makeSlug (Path.GetFileNameWithoutExtension upload.FileName),
|
||||||
Path.GetExtension(upload.FileName).ToLowerInvariant ())
|
Path.GetExtension(upload.FileName).ToLowerInvariant ())
|
||||||
let localNow = WebLog.localTime ctx.WebLog DateTime.Now
|
let now = Noda.now ()
|
||||||
|
let localNow = WebLog.localTime ctx.WebLog now
|
||||||
let year = localNow.ToString "yyyy"
|
let year = localNow.ToString "yyyy"
|
||||||
let month = localNow.ToString "MM"
|
let month = localNow.ToString "MM"
|
||||||
let! form = ctx.BindFormAsync<UploadFileModel> ()
|
let! form = ctx.BindFormAsync<UploadFileModel> ()
|
||||||
@@ -156,7 +159,7 @@ let save : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
|||||||
{ Id = UploadId.create ()
|
{ Id = UploadId.create ()
|
||||||
WebLogId = ctx.WebLog.Id
|
WebLogId = ctx.WebLog.Id
|
||||||
Path = Permalink $"{year}/{month}/{fileName}"
|
Path = Permalink $"{year}/{month}/{fileName}"
|
||||||
UpdatedOn = DateTime.UtcNow
|
UpdatedOn = now
|
||||||
Data = stream.ToArray ()
|
Data = stream.ToArray ()
|
||||||
}
|
}
|
||||||
do! ctx.Data.Upload.Add file
|
do! ctx.Data.Upload.Add file
|
||||||
|
|||||||
@@ -2,19 +2,32 @@
|
|||||||
module MyWebLog.Handlers.User
|
module MyWebLog.Handlers.User
|
||||||
|
|
||||||
open System
|
open System
|
||||||
open System.Security.Cryptography
|
open Microsoft.AspNetCore.Http
|
||||||
open System.Text
|
open Microsoft.AspNetCore.Identity
|
||||||
|
open MyWebLog
|
||||||
|
open NodaTime
|
||||||
|
|
||||||
// ~~ LOG ON / LOG OFF ~~
|
// ~~ LOG ON / LOG OFF ~~
|
||||||
|
|
||||||
/// Hash a password for a given user
|
/// Create a password hash a password for a given user
|
||||||
let hashedPassword (plainText : string) (email : string) (salt : Guid) =
|
let createPasswordHash user password =
|
||||||
let allSalt = Array.concat [ salt.ToByteArray (); Encoding.UTF8.GetBytes email ]
|
PasswordHasher<WebLogUser>().HashPassword (user, password)
|
||||||
use alg = new Rfc2898DeriveBytes (plainText, allSalt, 2_048)
|
|
||||||
Convert.ToBase64String (alg.GetBytes 64)
|
/// Verify whether a password is valid
|
||||||
|
let verifyPassword user password (ctx : HttpContext) = backgroundTask {
|
||||||
|
match user with
|
||||||
|
| Some usr ->
|
||||||
|
let hasher = PasswordHasher<WebLogUser> ()
|
||||||
|
match hasher.VerifyHashedPassword (usr, usr.PasswordHash, password) with
|
||||||
|
| PasswordVerificationResult.Success -> return Ok ()
|
||||||
|
| PasswordVerificationResult.SuccessRehashNeeded ->
|
||||||
|
do! ctx.Data.WebLogUser.Update { usr with PasswordHash = hasher.HashPassword (usr, password) }
|
||||||
|
return Ok ()
|
||||||
|
| _ -> return Error "Log on attempt unsuccessful"
|
||||||
|
| None -> return Error "Log on attempt unsuccessful"
|
||||||
|
}
|
||||||
|
|
||||||
open Giraffe
|
open Giraffe
|
||||||
open MyWebLog
|
|
||||||
open MyWebLog.ViewModels
|
open MyWebLog.ViewModels
|
||||||
|
|
||||||
// GET /user/log-on
|
// GET /user/log-on
|
||||||
@@ -35,10 +48,12 @@ open Microsoft.AspNetCore.Authentication.Cookies
|
|||||||
|
|
||||||
// POST /user/log-on
|
// POST /user/log-on
|
||||||
let doLogOn : HttpHandler = fun next ctx -> task {
|
let doLogOn : HttpHandler = fun next ctx -> task {
|
||||||
let! model = ctx.BindFormAsync<LogOnModel> ()
|
let! model = ctx.BindFormAsync<LogOnModel> ()
|
||||||
let data = ctx.Data
|
let data = ctx.Data
|
||||||
match! data.WebLogUser.FindByEmail model.EmailAddress ctx.WebLog.Id with
|
let! tryUser = data.WebLogUser.FindByEmail model.EmailAddress ctx.WebLog.Id
|
||||||
| Some user when user.PasswordHash = hashedPassword model.Password user.Email user.Salt ->
|
match! verifyPassword tryUser model.Password ctx with
|
||||||
|
| Ok _ ->
|
||||||
|
let user = tryUser.Value
|
||||||
let claims = seq {
|
let claims = seq {
|
||||||
Claim (ClaimTypes.NameIdentifier, WebLogUserId.toString user.Id)
|
Claim (ClaimTypes.NameIdentifier, WebLogUserId.toString user.Id)
|
||||||
Claim (ClaimTypes.Name, $"{user.FirstName} {user.LastName}")
|
Claim (ClaimTypes.Name, $"{user.FirstName} {user.LastName}")
|
||||||
@@ -51,13 +66,16 @@ let doLogOn : HttpHandler = fun next ctx -> task {
|
|||||||
AuthenticationProperties (IssuedUtc = DateTimeOffset.UtcNow))
|
AuthenticationProperties (IssuedUtc = DateTimeOffset.UtcNow))
|
||||||
do! data.WebLogUser.SetLastSeen user.Id user.WebLogId
|
do! data.WebLogUser.SetLastSeen user.Id user.WebLogId
|
||||||
do! addMessage ctx
|
do! addMessage ctx
|
||||||
{ UserMessage.success with Message = $"Logged on successfully | Welcome to {ctx.WebLog.Name}!" }
|
{ UserMessage.success with
|
||||||
|
Message = "Log on successful"
|
||||||
|
Detail = Some $"Welcome to {ctx.WebLog.Name}!"
|
||||||
|
}
|
||||||
return!
|
return!
|
||||||
match model.ReturnTo with
|
match model.ReturnTo with
|
||||||
| Some url -> redirectTo false url next ctx
|
| Some url -> redirectTo false url next ctx
|
||||||
| None -> redirectToGet "admin/dashboard" next ctx
|
| None -> redirectToGet "admin/dashboard" next ctx
|
||||||
| _ ->
|
| Error msg ->
|
||||||
do! addMessage ctx { UserMessage.error with Message = "Log on attempt unsuccessful" }
|
do! addMessage ctx { UserMessage.error with Message = msg }
|
||||||
return! logOn model.ReturnTo next ctx
|
return! logOn model.ReturnTo next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,34 +90,18 @@ let logOff : HttpHandler = fun next ctx -> task {
|
|||||||
|
|
||||||
open System.Collections.Generic
|
open System.Collections.Generic
|
||||||
open Giraffe.Htmx
|
open Giraffe.Htmx
|
||||||
open Microsoft.AspNetCore.Http
|
|
||||||
|
|
||||||
/// Create the hash needed to display the user list
|
/// Got no time for URL/form manipulators...
|
||||||
let private userListHash (ctx : HttpContext) = task {
|
let private goAway : HttpHandler = RequestErrors.BAD_REQUEST "really?"
|
||||||
|
|
||||||
|
// GET /admin/settings/users
|
||||||
|
let all : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||||
let! users = ctx.Data.WebLogUser.FindByWebLog ctx.WebLog.Id
|
let! users = ctx.Data.WebLogUser.FindByWebLog ctx.WebLog.Id
|
||||||
return!
|
return!
|
||||||
hashForPage "User Administration"
|
hashForPage "User Administration"
|
||||||
|> withAntiCsrf ctx
|
|> withAntiCsrf ctx
|
||||||
|> addToHash "users" (users |> List.map (DisplayUser.fromUser ctx.WebLog) |> Array.ofList)
|
|> addToHash "users" (users |> List.map (DisplayUser.fromUser ctx.WebLog) |> Array.ofList)
|
||||||
|> addViewContext ctx
|
|> adminBareView "user-list-body" next ctx
|
||||||
}
|
|
||||||
|
|
||||||
/// Got no time for URL/form manipulators...
|
|
||||||
let private goAway : HttpHandler = RequestErrors.BAD_REQUEST "really?"
|
|
||||||
|
|
||||||
// GET /admin/users
|
|
||||||
let all : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
|
||||||
let! hash = userListHash ctx
|
|
||||||
let! tmpl = TemplateCache.get "admin" "user-list-body" ctx.Data
|
|
||||||
return!
|
|
||||||
addToHash "user_list" (tmpl.Render hash) hash
|
|
||||||
|> adminView "user-list" next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /admin/users/bare
|
|
||||||
let bare : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
|
||||||
let! hash = userListHash ctx
|
|
||||||
return! adminBareView "user-list-body" next ctx hash
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show the edit user page
|
/// Show the edit user page
|
||||||
@@ -116,7 +118,7 @@ let private showEdit (model : EditUserModel) : HttpHandler = fun next ctx ->
|
|||||||
|]
|
|]
|
||||||
|> adminBareView "user-edit" next ctx
|
|> adminBareView "user-edit" next ctx
|
||||||
|
|
||||||
// GET /admin/user/{id}/edit
|
// GET /admin/settings/user/{id}/edit
|
||||||
let edit usrId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
let edit usrId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||||
let isNew = usrId = "new"
|
let isNew = usrId = "new"
|
||||||
let userId = WebLogUserId usrId
|
let userId = WebLogUserId usrId
|
||||||
@@ -128,44 +130,7 @@ let edit usrId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> tas
|
|||||||
| None -> return! Error.notFound next ctx
|
| None -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /admin/user/save
|
// POST /admin/settings/user/{id}/delete
|
||||||
let save : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
|
||||||
let! model = ctx.BindFormAsync<EditUserModel> ()
|
|
||||||
let data = ctx.Data
|
|
||||||
let tryUser =
|
|
||||||
if model.IsNew then
|
|
||||||
{ WebLogUser.empty with
|
|
||||||
Id = WebLogUserId.create ()
|
|
||||||
WebLogId = ctx.WebLog.Id
|
|
||||||
CreatedOn = DateTime.UtcNow
|
|
||||||
} |> someTask
|
|
||||||
else data.WebLogUser.FindById (WebLogUserId model.Id) ctx.WebLog.Id
|
|
||||||
match! tryUser with
|
|
||||||
| Some user when model.Password = model.PasswordConfirm ->
|
|
||||||
let updatedUser = model.UpdateUser user
|
|
||||||
if updatedUser.AccessLevel = Administrator && not (ctx.HasAccessLevel Administrator) then
|
|
||||||
return! goAway next ctx
|
|
||||||
else
|
|
||||||
let updatedUser =
|
|
||||||
if model.Password = "" then updatedUser
|
|
||||||
else
|
|
||||||
let salt = Guid.NewGuid ()
|
|
||||||
{ updatedUser with PasswordHash = hashedPassword model.Password model.Email salt; Salt = salt }
|
|
||||||
do! (if model.IsNew then data.WebLogUser.Add else data.WebLogUser.Update) updatedUser
|
|
||||||
do! addMessage ctx
|
|
||||||
{ UserMessage.success with
|
|
||||||
Message = $"""{if model.IsNew then "Add" else "Updat"}ed user successfully"""
|
|
||||||
}
|
|
||||||
return! bare next ctx
|
|
||||||
| Some _ ->
|
|
||||||
do! addMessage ctx { UserMessage.error with Message = "The passwords did not match; nothing saved" }
|
|
||||||
return!
|
|
||||||
(withHxRetarget $"#user_{model.Id}" >=> showEdit { model with Password = ""; PasswordConfirm = "" })
|
|
||||||
next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /admin/user/{id}/delete
|
|
||||||
let delete userId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
let delete userId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||||
let data = ctx.Data
|
let data = ctx.Data
|
||||||
match! data.WebLogUser.FindById (WebLogUserId userId) ctx.WebLog.Id with
|
match! data.WebLogUser.FindById (WebLogUserId userId) ctx.WebLog.Id with
|
||||||
@@ -179,14 +144,14 @@ let delete userId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx ->
|
|||||||
{ UserMessage.success with
|
{ UserMessage.success with
|
||||||
Message = $"User {WebLogUser.displayName user} deleted successfully"
|
Message = $"User {WebLogUser.displayName user} deleted successfully"
|
||||||
}
|
}
|
||||||
return! bare next ctx
|
return! all next ctx
|
||||||
| Error msg ->
|
| Error msg ->
|
||||||
do! addMessage ctx
|
do! addMessage ctx
|
||||||
{ UserMessage.error with
|
{ UserMessage.error with
|
||||||
Message = $"User {WebLogUser.displayName user} was not deleted"
|
Message = $"User {WebLogUser.displayName user} was not deleted"
|
||||||
Detail = Some msg
|
Detail = Some msg
|
||||||
}
|
}
|
||||||
return! bare next ctx
|
return! all next ctx
|
||||||
| None -> return! Error.notFound next ctx
|
| None -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,43 +162,77 @@ let private showMyInfo (model : EditMyInfoModel) (user : WebLogUser) : HttpHandl
|
|||||||
|> addToHash ViewContext.Model model
|
|> addToHash ViewContext.Model model
|
||||||
|> addToHash "access_level" (AccessLevel.toString user.AccessLevel)
|
|> addToHash "access_level" (AccessLevel.toString user.AccessLevel)
|
||||||
|> addToHash "created_on" (WebLog.localTime ctx.WebLog user.CreatedOn)
|
|> addToHash "created_on" (WebLog.localTime ctx.WebLog user.CreatedOn)
|
||||||
|> addToHash "last_seen_on" (WebLog.localTime ctx.WebLog (defaultArg user.LastSeenOn DateTime.UnixEpoch))
|
|> addToHash "last_seen_on" (WebLog.localTime ctx.WebLog
|
||||||
|
(defaultArg user.LastSeenOn (Instant.FromUnixTimeSeconds 0)))
|
||||||
|
|
||||||
|> adminView "my-info" next ctx
|
|> adminView "my-info" next ctx
|
||||||
|
|
||||||
|
|
||||||
// GET /admin/user/my-info
|
// GET /admin/my-info
|
||||||
let myInfo : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
let myInfo : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
||||||
match! ctx.Data.WebLogUser.FindById ctx.UserId ctx.WebLog.Id with
|
match! ctx.Data.WebLogUser.FindById ctx.UserId ctx.WebLog.Id with
|
||||||
| Some user -> return! showMyInfo (EditMyInfoModel.fromUser user) user next ctx
|
| Some user -> return! showMyInfo (EditMyInfoModel.fromUser user) user next ctx
|
||||||
| None -> return! Error.notFound next ctx
|
| None -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /admin/user/my-info
|
// POST /admin/my-info
|
||||||
let saveMyInfo : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
let saveMyInfo : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
||||||
let! model = ctx.BindFormAsync<EditMyInfoModel> ()
|
let! model = ctx.BindFormAsync<EditMyInfoModel> ()
|
||||||
let data = ctx.Data
|
let data = ctx.Data
|
||||||
match! data.WebLogUser.FindById ctx.UserId ctx.WebLog.Id with
|
match! data.WebLogUser.FindById ctx.UserId ctx.WebLog.Id with
|
||||||
| Some user when model.NewPassword = model.NewPasswordConfirm ->
|
| Some user when model.NewPassword = model.NewPasswordConfirm ->
|
||||||
let pw, salt =
|
let pw = if model.NewPassword = "" then user.PasswordHash else createPasswordHash user model.NewPassword
|
||||||
if model.NewPassword = "" then
|
|
||||||
user.PasswordHash, user.Salt
|
|
||||||
else
|
|
||||||
let newSalt = Guid.NewGuid ()
|
|
||||||
hashedPassword model.NewPassword user.Email newSalt, newSalt
|
|
||||||
let user =
|
let user =
|
||||||
{ user with
|
{ user with
|
||||||
FirstName = model.FirstName
|
FirstName = model.FirstName
|
||||||
LastName = model.LastName
|
LastName = model.LastName
|
||||||
PreferredName = model.PreferredName
|
PreferredName = model.PreferredName
|
||||||
PasswordHash = pw
|
PasswordHash = pw
|
||||||
Salt = salt
|
|
||||||
}
|
}
|
||||||
do! data.WebLogUser.Update user
|
do! data.WebLogUser.Update user
|
||||||
let pwMsg = if model.NewPassword = "" then "" else " and updated your password"
|
let pwMsg = if model.NewPassword = "" then "" else " and updated your password"
|
||||||
do! addMessage ctx { UserMessage.success with Message = $"Saved your information{pwMsg} successfully" }
|
do! addMessage ctx { UserMessage.success with Message = $"Saved your information{pwMsg} successfully" }
|
||||||
return! redirectToGet "admin/user/my-info" next ctx
|
return! redirectToGet "admin/my-info" next ctx
|
||||||
| Some user ->
|
| Some user ->
|
||||||
do! addMessage ctx { UserMessage.error with Message = "Passwords did not match; no updates made" }
|
do! addMessage ctx { UserMessage.error with Message = "Passwords did not match; no updates made" }
|
||||||
return! showMyInfo { model with NewPassword = ""; NewPasswordConfirm = "" } user next ctx
|
return! showMyInfo { model with NewPassword = ""; NewPasswordConfirm = "" } user next ctx
|
||||||
| None -> return! Error.notFound next ctx
|
| None -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User save is not statically compilable; not sure why, but we'll revisit it at some point
|
||||||
|
#nowarn "3511"
|
||||||
|
|
||||||
|
// POST /admin/settings/user/save
|
||||||
|
let save : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||||
|
let! model = ctx.BindFormAsync<EditUserModel> ()
|
||||||
|
let data = ctx.Data
|
||||||
|
let tryUser =
|
||||||
|
if model.IsNew then
|
||||||
|
{ WebLogUser.empty with
|
||||||
|
Id = WebLogUserId.create ()
|
||||||
|
WebLogId = ctx.WebLog.Id
|
||||||
|
CreatedOn = Noda.now ()
|
||||||
|
} |> someTask
|
||||||
|
else data.WebLogUser.FindById (WebLogUserId model.Id) ctx.WebLog.Id
|
||||||
|
match! tryUser with
|
||||||
|
| Some user when model.Password = model.PasswordConfirm ->
|
||||||
|
let updatedUser = model.UpdateUser user
|
||||||
|
if updatedUser.AccessLevel = Administrator && not (ctx.HasAccessLevel Administrator) then
|
||||||
|
return! goAway next ctx
|
||||||
|
else
|
||||||
|
let toUpdate =
|
||||||
|
if model.Password = "" then updatedUser
|
||||||
|
else { updatedUser with PasswordHash = createPasswordHash updatedUser model.Password }
|
||||||
|
do! (if model.IsNew then data.WebLogUser.Add else data.WebLogUser.Update) toUpdate
|
||||||
|
do! addMessage ctx
|
||||||
|
{ UserMessage.success with
|
||||||
|
Message = $"""{if model.IsNew then "Add" else "Updat"}ed user successfully"""
|
||||||
|
}
|
||||||
|
return! all next ctx
|
||||||
|
| Some _ ->
|
||||||
|
do! addMessage ctx { UserMessage.error with Message = "The passwords did not match; nothing saved" }
|
||||||
|
return!
|
||||||
|
(withHxRetarget $"#user_{model.Id}" >=> showEdit { model with Password = ""; PasswordConfirm = "" })
|
||||||
|
next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ open System
|
|||||||
open System.IO
|
open System.IO
|
||||||
open Microsoft.Extensions.DependencyInjection
|
open Microsoft.Extensions.DependencyInjection
|
||||||
open MyWebLog.Data
|
open MyWebLog.Data
|
||||||
|
open NodaTime
|
||||||
|
|
||||||
/// Create the web log information
|
/// Create the web log information
|
||||||
let private doCreateWebLog (args : string[]) (sp : IServiceProvider) = task {
|
let private doCreateWebLog (args : string[]) (sp : IServiceProvider) = task {
|
||||||
@@ -41,22 +42,19 @@ let private doCreateWebLog (args : string[]) (sp : IServiceProvider) = task {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create the admin user
|
// Create the admin user
|
||||||
let salt = Guid.NewGuid ()
|
let now = Noda.now ()
|
||||||
let now = DateTime.UtcNow
|
let user =
|
||||||
|
{ WebLogUser.empty with
|
||||||
do! data.WebLogUser.Add
|
Id = userId
|
||||||
{ WebLogUser.empty with
|
WebLogId = webLogId
|
||||||
Id = userId
|
Email = args[3]
|
||||||
WebLogId = webLogId
|
FirstName = "Admin"
|
||||||
Email = args[3]
|
LastName = "User"
|
||||||
FirstName = "Admin"
|
PreferredName = "Admin"
|
||||||
LastName = "User"
|
AccessLevel = accessLevel
|
||||||
PreferredName = "Admin"
|
CreatedOn = now
|
||||||
PasswordHash = Handlers.User.hashedPassword args[4] args[3] salt
|
}
|
||||||
Salt = salt
|
do! data.WebLogUser.Add { user with PasswordHash = Handlers.User.createPasswordHash user args[4] }
|
||||||
AccessLevel = accessLevel
|
|
||||||
CreatedOn = now
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the default home page
|
// Create the default home page
|
||||||
do! data.Page.Add
|
do! data.Page.Add
|
||||||
@@ -70,8 +68,8 @@ let private doCreateWebLog (args : string[]) (sp : IServiceProvider) = task {
|
|||||||
UpdatedOn = now
|
UpdatedOn = now
|
||||||
Text = "<p>This is your default home page.</p>"
|
Text = "<p>This is your default home page.</p>"
|
||||||
Revisions = [
|
Revisions = [
|
||||||
{ AsOf = now
|
{ AsOf = now
|
||||||
Text = Html "<p>This is your default home page.</p>"
|
Text = Html "<p>This is your default home page.</p>"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -89,7 +87,7 @@ let private doCreateWebLog (args : string[]) (sp : IServiceProvider) = task {
|
|||||||
let createWebLog args sp = task {
|
let createWebLog args sp = task {
|
||||||
match args |> Array.length with
|
match args |> Array.length with
|
||||||
| 5 -> do! doCreateWebLog args sp
|
| 5 -> do! doCreateWebLog args sp
|
||||||
| _ -> eprintfn "Usage: MyWebLog init [url] [name] [admin-email] [admin-pw]"
|
| _ -> eprintfn "Usage: myWebLog init [url] [name] [admin-email] [admin-pw]"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Import prior permalinks from a text files with lines in the format "[old] [new]"
|
/// Import prior permalinks from a text files with lines in the format "[old] [new]"
|
||||||
@@ -122,38 +120,39 @@ let private importPriorPermalinks urlBase file (sp : IServiceProvider) = task {
|
|||||||
let importLinks args sp = task {
|
let importLinks args sp = task {
|
||||||
match args |> Array.length with
|
match args |> Array.length with
|
||||||
| 3 -> do! importPriorPermalinks args[1] args[2] sp
|
| 3 -> do! importPriorPermalinks args[1] args[2] sp
|
||||||
| _ -> eprintfn "Usage: MyWebLog import-links [url] [file-name]"
|
| _ -> eprintfn "Usage: myWebLog import-links [url] [file-name]"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading a theme and restoring a backup are not statically compilable; this is OK
|
// Loading a theme and restoring a backup are not statically compilable; this is OK
|
||||||
#nowarn "3511"
|
#nowarn "3511"
|
||||||
|
|
||||||
|
open Microsoft.Extensions.Logging
|
||||||
|
|
||||||
/// Load a theme from the given ZIP file
|
/// Load a theme from the given ZIP file
|
||||||
let loadTheme (args : string[]) (sp : IServiceProvider) = task {
|
let loadTheme (args : string[]) (sp : IServiceProvider) = task {
|
||||||
if args.Length > 1 then
|
if args.Length = 2 then
|
||||||
let fileName =
|
let fileName =
|
||||||
match args[1].LastIndexOf Path.DirectorySeparatorChar with
|
match args[1].LastIndexOf Path.DirectorySeparatorChar with
|
||||||
| -1 -> args[1]
|
| -1 -> args[1]
|
||||||
| it -> args[1][(it + 1)..]
|
| it -> args[1][(it + 1)..]
|
||||||
match Handlers.Admin.getThemeName fileName with
|
match Handlers.Admin.Theme.deriveIdFromFileName fileName with
|
||||||
| Ok themeName ->
|
| Ok themeId ->
|
||||||
let data = sp.GetRequiredService<IData> ()
|
let data = sp.GetRequiredService<IData> ()
|
||||||
let clean = if args.Length > 2 then bool.Parse args[2] else true
|
|
||||||
use stream = File.Open (args[1], FileMode.Open)
|
use stream = File.Open (args[1], FileMode.Open)
|
||||||
use copy = new MemoryStream ()
|
use copy = new MemoryStream ()
|
||||||
do! stream.CopyToAsync copy
|
do! stream.CopyToAsync copy
|
||||||
do! Handlers.Admin.loadThemeFromZip themeName copy clean data
|
let! theme = Handlers.Admin.Theme.loadFromZip themeId copy data
|
||||||
printfn $"Theme {themeName} loaded successfully"
|
let fac = sp.GetRequiredService<ILoggerFactory> ()
|
||||||
|
let log = fac.CreateLogger "MyWebLog.Themes"
|
||||||
|
log.LogInformation $"{theme.Name} v{theme.Version} ({ThemeId.toString theme.Id}) loaded"
|
||||||
| Error message -> eprintfn $"{message}"
|
| Error message -> eprintfn $"{message}"
|
||||||
else
|
else
|
||||||
eprintfn "Usage: MyWebLog load-theme [theme-zip-file-name] [*clean-load]"
|
eprintfn "Usage: myWebLog load-theme [theme-zip-file-name]"
|
||||||
eprintfn " * optional, defaults to true"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Back up a web log's data
|
/// Back up a web log's data
|
||||||
module Backup =
|
module Backup =
|
||||||
|
|
||||||
open System.Threading.Tasks
|
|
||||||
open MyWebLog.Converters
|
open MyWebLog.Converters
|
||||||
open Newtonsoft.Json
|
open Newtonsoft.Json
|
||||||
|
|
||||||
@@ -163,7 +162,7 @@ module Backup =
|
|||||||
Id : ThemeAssetId
|
Id : ThemeAssetId
|
||||||
|
|
||||||
/// The updated date for this asset
|
/// The updated date for this asset
|
||||||
UpdatedOn : DateTime
|
UpdatedOn : Instant
|
||||||
|
|
||||||
/// The data for this asset, base-64 encoded
|
/// The data for this asset, base-64 encoded
|
||||||
Data : string
|
Data : string
|
||||||
@@ -195,7 +194,7 @@ module Backup =
|
|||||||
Path : Permalink
|
Path : Permalink
|
||||||
|
|
||||||
/// The date/time this upload was last updated (file time)
|
/// The date/time this upload was last updated (file time)
|
||||||
UpdatedOn : DateTime
|
UpdatedOn : Instant
|
||||||
|
|
||||||
/// The data for the upload, base-64 encoded
|
/// The data for the upload, base-64 encoded
|
||||||
Data : string
|
Data : string
|
||||||
@@ -249,10 +248,9 @@ module Backup =
|
|||||||
Uploads : EncodedUpload list
|
Uploads : EncodedUpload list
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a JSON serializer (uses RethinkDB data implementation's JSON converters)
|
/// Create a JSON serializer
|
||||||
let private getSerializer prettyOutput =
|
let private getSerializer prettyOutput =
|
||||||
let serializer = JsonSerializer.CreateDefault ()
|
let serializer = Json.configure (JsonSerializer.CreateDefault ())
|
||||||
Json.all () |> Seq.iter serializer.Converters.Add
|
|
||||||
if prettyOutput then serializer.Formatting <- Formatting.Indented
|
if prettyOutput then serializer.Formatting <- Formatting.Indented
|
||||||
serializer
|
serializer
|
||||||
|
|
||||||
@@ -380,7 +378,8 @@ module Backup =
|
|||||||
printfn ""
|
printfn ""
|
||||||
printfn "- Importing theme..."
|
printfn "- Importing theme..."
|
||||||
do! data.Theme.Save restore.Theme
|
do! data.Theme.Save restore.Theme
|
||||||
let! _ = restore.Assets |> List.map (EncodedAsset.toAsset >> data.ThemeAsset.Save) |> Task.WhenAll
|
restore.Assets
|
||||||
|
|> List.iter (EncodedAsset.toAsset >> data.ThemeAsset.Save >> Async.AwaitTask >> Async.RunSynchronously)
|
||||||
|
|
||||||
// Restore web log data
|
// Restore web log data
|
||||||
|
|
||||||
@@ -391,19 +390,20 @@ module Backup =
|
|||||||
do! data.WebLogUser.Restore restore.Users
|
do! data.WebLogUser.Restore restore.Users
|
||||||
|
|
||||||
printfn "- Restoring categories and tag mappings..."
|
printfn "- Restoring categories and tag mappings..."
|
||||||
do! data.TagMap.Restore restore.TagMappings
|
if not (List.isEmpty restore.TagMappings) then do! data.TagMap.Restore restore.TagMappings
|
||||||
do! data.Category.Restore restore.Categories
|
if not (List.isEmpty restore.Categories) then do! data.Category.Restore restore.Categories
|
||||||
|
|
||||||
printfn "- Restoring pages..."
|
printfn "- Restoring pages..."
|
||||||
do! data.Page.Restore restore.Pages
|
if not (List.isEmpty restore.Pages) then do! data.Page.Restore restore.Pages
|
||||||
|
|
||||||
printfn "- Restoring posts..."
|
printfn "- Restoring posts..."
|
||||||
do! data.Post.Restore restore.Posts
|
if not (List.isEmpty restore.Posts) then do! data.Post.Restore restore.Posts
|
||||||
|
|
||||||
// TODO: comments not yet implemented
|
// TODO: comments not yet implemented
|
||||||
|
|
||||||
printfn "- Restoring uploads..."
|
printfn "- Restoring uploads..."
|
||||||
do! data.Upload.Restore (restore.Uploads |> List.map EncodedUpload.toUpload)
|
if not (List.isEmpty restore.Uploads) then
|
||||||
|
do! data.Upload.Restore (restore.Uploads |> List.map EncodedUpload.toUpload)
|
||||||
|
|
||||||
displayStats "Restored for <>NAME<>:" restore.WebLog restore
|
displayStats "Restored for <>NAME<>:" restore.WebLog restore
|
||||||
}
|
}
|
||||||
@@ -449,7 +449,7 @@ module Backup =
|
|||||||
do! createBackup webLog fileName prettyOutput data
|
do! createBackup webLog fileName prettyOutput data
|
||||||
| None -> eprintfn $"Error: no web log found for {args[1]}"
|
| None -> eprintfn $"Error: no web log found for {args[1]}"
|
||||||
else
|
else
|
||||||
eprintfn """Usage: MyWebLog backup [url-base] [*backup-file-name] [**"pretty"]"""
|
eprintfn """Usage: myWebLog backup [url-base] [*backup-file-name] [**"pretty"]"""
|
||||||
eprintfn """ * optional - default is [web-log-slug].json"""
|
eprintfn """ * optional - default is [web-log-slug].json"""
|
||||||
eprintfn """ ** optional - default is non-pretty JSON output"""
|
eprintfn """ ** optional - default is non-pretty JSON output"""
|
||||||
}
|
}
|
||||||
@@ -461,7 +461,7 @@ module Backup =
|
|||||||
let newUrlBase = if args.Length = 3 then Some args[2] else None
|
let newUrlBase = if args.Length = 3 then Some args[2] else None
|
||||||
do! restoreBackup args[1] newUrlBase (args[0] <> "do-restore") data
|
do! restoreBackup args[1] newUrlBase (args[0] <> "do-restore") data
|
||||||
else
|
else
|
||||||
eprintfn "Usage: MyWebLog restore [backup-file-name] [*url-base]"
|
eprintfn "Usage: myWebLog restore [backup-file-name] [*url-base]"
|
||||||
eprintfn " * optional - will restore to original URL base if omitted"
|
eprintfn " * optional - will restore to original URL base if omitted"
|
||||||
eprintfn " (use do-restore to skip confirmation prompt)"
|
eprintfn " (use do-restore to skip confirmation prompt)"
|
||||||
}
|
}
|
||||||
@@ -486,5 +486,24 @@ let private doUserUpgrade urlBase email (data : IData) = task {
|
|||||||
let upgradeUser (args : string[]) (sp : IServiceProvider) = task {
|
let upgradeUser (args : string[]) (sp : IServiceProvider) = task {
|
||||||
match args.Length with
|
match args.Length with
|
||||||
| 3 -> do! doUserUpgrade args[1] args[2] (sp.GetRequiredService<IData> ())
|
| 3 -> do! doUserUpgrade args[1] args[2] (sp.GetRequiredService<IData> ())
|
||||||
| _ -> eprintfn "Usage: MyWebLog upgrade-user [web-log-url-base] [email-address]"
|
| _ -> eprintfn "Usage: myWebLog upgrade-user [web-log-url-base] [email-address]"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a user's password
|
||||||
|
let doSetPassword urlBase email password (data : IData) = task {
|
||||||
|
match! data.WebLog.FindByHost urlBase with
|
||||||
|
| Some webLog ->
|
||||||
|
match! data.WebLogUser.FindByEmail email webLog.Id with
|
||||||
|
| Some user ->
|
||||||
|
do! data.WebLogUser.Update { user with PasswordHash = Handlers.User.createPasswordHash user password }
|
||||||
|
printfn $"Password for user {email} at {webLog.Name} set successfully"
|
||||||
|
| None -> eprintfn $"ERROR: no user {email} found at {urlBase}"
|
||||||
|
| None -> eprintfn $"ERROR: no web log found for {urlBase}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a user's password if the command-line arguments are good
|
||||||
|
let setPassword (args : string[]) (sp : IServiceProvider) = task {
|
||||||
|
match args.Length with
|
||||||
|
| 4 -> do! doSetPassword args[1] args[2] args[3] (sp.GetRequiredService<IData> ())
|
||||||
|
| _ -> eprintfn "Usage: myWebLog set-password [web-log-url-base] [email-address] [password]"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,8 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
|
||||||
<PublishSingleFile>true</PublishSingleFile>
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
<SelfContained>false</SelfContained>
|
<SelfContained>false</SelfContained>
|
||||||
<DebugType>embedded</DebugType>
|
|
||||||
<NoWarn>3391</NoWarn>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -29,11 +29,14 @@ type WebLogMiddleware (next : RequestDelegate, log : ILogger<WebLogMiddleware>)
|
|||||||
open System
|
open System
|
||||||
open Microsoft.Extensions.DependencyInjection
|
open Microsoft.Extensions.DependencyInjection
|
||||||
open MyWebLog.Data
|
open MyWebLog.Data
|
||||||
|
open Newtonsoft.Json
|
||||||
|
open Npgsql
|
||||||
|
|
||||||
/// Logic to obtain a data connection and implementation based on configured values
|
/// Logic to obtain a data connection and implementation based on configured values
|
||||||
module DataImplementation =
|
module DataImplementation =
|
||||||
|
|
||||||
open MyWebLog.Converters
|
open MyWebLog.Converters
|
||||||
|
// open Npgsql.Logging
|
||||||
open RethinkDb.Driver.FSharp
|
open RethinkDb.Driver.FSharp
|
||||||
open RethinkDb.Driver.Net
|
open RethinkDb.Driver.Net
|
||||||
|
|
||||||
@@ -43,23 +46,29 @@ module DataImplementation =
|
|||||||
let await it = (Async.AwaitTask >> Async.RunSynchronously) it
|
let await it = (Async.AwaitTask >> Async.RunSynchronously) it
|
||||||
let connStr name = config.GetConnectionString name
|
let connStr name = config.GetConnectionString name
|
||||||
let hasConnStr name = (connStr >> isNull >> not) name
|
let hasConnStr name = (connStr >> isNull >> not) name
|
||||||
let createSQLite connStr =
|
let createSQLite connStr : IData =
|
||||||
let log = sp.GetRequiredService<ILogger<SQLiteData>> ()
|
let log = sp.GetRequiredService<ILogger<SQLiteData>> ()
|
||||||
let conn = new SqliteConnection (connStr)
|
let conn = new SqliteConnection (connStr)
|
||||||
log.LogInformation $"Using SQLite database {conn.DataSource}"
|
log.LogInformation $"Using SQLite database {conn.DataSource}"
|
||||||
await (SQLiteData.setUpConnection conn)
|
await (SQLiteData.setUpConnection conn)
|
||||||
SQLiteData (conn, log)
|
SQLiteData (conn, log, Json.configure (JsonSerializer.CreateDefault ()))
|
||||||
|
|
||||||
if hasConnStr "SQLite" then
|
if hasConnStr "SQLite" then
|
||||||
upcast createSQLite (connStr "SQLite")
|
createSQLite (connStr "SQLite")
|
||||||
elif hasConnStr "RethinkDB" then
|
elif hasConnStr "RethinkDB" then
|
||||||
let log = sp.GetRequiredService<ILogger<RethinkDbData>> ()
|
let log = sp.GetRequiredService<ILogger<RethinkDbData>> ()
|
||||||
Json.all () |> Seq.iter Converter.Serializer.Converters.Add
|
let _ = Json.configure Converter.Serializer
|
||||||
let rethinkCfg = DataConfig.FromUri (connStr "RethinkDB")
|
let rethinkCfg = DataConfig.FromUri (connStr "RethinkDB")
|
||||||
let conn = await (rethinkCfg.CreateConnectionAsync log)
|
let conn = await (rethinkCfg.CreateConnectionAsync log)
|
||||||
upcast RethinkDbData (conn, rethinkCfg, log)
|
RethinkDbData (conn, rethinkCfg, log)
|
||||||
|
elif hasConnStr "PostgreSQL" then
|
||||||
|
let log = sp.GetRequiredService<ILogger<PostgresData>> ()
|
||||||
|
// NpgsqlLogManager.Provider <- ConsoleLoggingProvider NpgsqlLogLevel.Debug
|
||||||
|
let conn = new NpgsqlConnection (connStr "PostgreSQL")
|
||||||
|
log.LogInformation $"Using PostgreSQL database {conn.Host}:{conn.Port}/{conn.Database}"
|
||||||
|
PostgresData (conn, log, Json.configure (JsonSerializer.CreateDefault ()))
|
||||||
else
|
else
|
||||||
upcast createSQLite "Data Source=./myweblog.db;Cache=Shared"
|
createSQLite "Data Source=./myweblog.db;Cache=Shared"
|
||||||
|
|
||||||
|
|
||||||
open System.Threading.Tasks
|
open System.Threading.Tasks
|
||||||
@@ -76,17 +85,20 @@ let showHelp () =
|
|||||||
printfn "init Initializes a new web log"
|
printfn "init Initializes a new web log"
|
||||||
printfn "load-theme Load a theme"
|
printfn "load-theme Load a theme"
|
||||||
printfn "restore Restore a JSON file backup (prompt before overwriting)"
|
printfn "restore Restore a JSON file backup (prompt before overwriting)"
|
||||||
|
printfn "set-password Set a password for a specific user"
|
||||||
printfn "upgrade-user Upgrade a WebLogAdmin user to a full Administrator"
|
printfn "upgrade-user Upgrade a WebLogAdmin user to a full Administrator"
|
||||||
printfn " "
|
printfn " "
|
||||||
printfn "For more information on a particular command, run it with no options."
|
printfn "For more information on a particular command, run it with no options."
|
||||||
Task.FromResult ()
|
Task.FromResult ()
|
||||||
|
|
||||||
|
|
||||||
|
open System.IO
|
||||||
open Giraffe
|
open Giraffe
|
||||||
open Giraffe.EndpointRouting
|
open Giraffe.EndpointRouting
|
||||||
open Microsoft.AspNetCore.Authentication.Cookies
|
open Microsoft.AspNetCore.Authentication.Cookies
|
||||||
open Microsoft.AspNetCore.Builder
|
open Microsoft.AspNetCore.Builder
|
||||||
open Microsoft.AspNetCore.HttpOverrides
|
open Microsoft.AspNetCore.HttpOverrides
|
||||||
|
open Microsoft.Extensions.Caching.Distributed
|
||||||
open NeoSmart.Caching.Sqlite
|
open NeoSmart.Caching.Sqlite
|
||||||
open RethinkDB.DistributedCache
|
open RethinkDB.DistributedCache
|
||||||
|
|
||||||
@@ -107,8 +119,9 @@ let rec main args =
|
|||||||
let _ = builder.Services.AddAuthorization ()
|
let _ = builder.Services.AddAuthorization ()
|
||||||
let _ = builder.Services.AddAntiforgery ()
|
let _ = builder.Services.AddAntiforgery ()
|
||||||
|
|
||||||
let sp = builder.Services.BuildServiceProvider ()
|
let sp = builder.Services.BuildServiceProvider ()
|
||||||
let data = DataImplementation.get sp
|
let data = DataImplementation.get sp
|
||||||
|
let _ = builder.Services.AddSingleton<JsonSerializer> data.Serializer
|
||||||
|
|
||||||
task {
|
task {
|
||||||
do! data.StartUp ()
|
do! data.StartUp ()
|
||||||
@@ -120,23 +133,36 @@ let rec main args =
|
|||||||
match data with
|
match data with
|
||||||
| :? RethinkDbData as rethink ->
|
| :? RethinkDbData as rethink ->
|
||||||
// A RethinkDB connection is designed to work as a singleton
|
// A RethinkDB connection is designed to work as a singleton
|
||||||
builder.Services.AddSingleton<IData> data |> ignore
|
let _ = builder.Services.AddSingleton<IData> data
|
||||||
builder.Services.AddDistributedRethinkDBCache (fun opts ->
|
let _ =
|
||||||
opts.TableName <- "Session"
|
builder.Services.AddDistributedRethinkDBCache (fun opts ->
|
||||||
opts.Connection <- rethink.Conn)
|
opts.TableName <- "Session"
|
||||||
|> ignore
|
opts.Connection <- rethink.Conn)
|
||||||
|
()
|
||||||
| :? SQLiteData as sql ->
|
| :? SQLiteData as sql ->
|
||||||
// ADO.NET connections are designed to work as per-request instantiation
|
// ADO.NET connections are designed to work as per-request instantiation
|
||||||
let cfg = sp.GetRequiredService<IConfiguration> ()
|
let cfg = sp.GetRequiredService<IConfiguration> ()
|
||||||
builder.Services.AddScoped<SqliteConnection> (fun sp ->
|
let _ =
|
||||||
let conn = new SqliteConnection (sql.Conn.ConnectionString)
|
builder.Services.AddScoped<SqliteConnection> (fun sp ->
|
||||||
SQLiteData.setUpConnection conn |> Async.AwaitTask |> Async.RunSynchronously
|
let conn = new SqliteConnection (sql.Conn.ConnectionString)
|
||||||
conn)
|
SQLiteData.setUpConnection conn |> Async.AwaitTask |> Async.RunSynchronously
|
||||||
|> ignore
|
conn)
|
||||||
builder.Services.AddScoped<IData, SQLiteData> () |> ignore
|
let _ = builder.Services.AddScoped<IData, SQLiteData> () |> ignore
|
||||||
// Use SQLite for caching as well
|
// Use SQLite for caching as well
|
||||||
let cachePath = Option.ofObj (cfg.GetConnectionString "SQLiteCachePath") |> Option.defaultValue "./session.db"
|
let cachePath = defaultArg (Option.ofObj (cfg.GetConnectionString "SQLiteCachePath")) "./session.db"
|
||||||
builder.Services.AddSqliteCache (fun o -> o.CachePath <- cachePath) |> ignore
|
let _ = builder.Services.AddSqliteCache (fun o -> o.CachePath <- cachePath)
|
||||||
|
()
|
||||||
|
| :? PostgresData ->
|
||||||
|
// ADO.NET connections are designed to work as per-request instantiation
|
||||||
|
let cfg = sp.GetRequiredService<IConfiguration> ()
|
||||||
|
let _ =
|
||||||
|
builder.Services.AddScoped<NpgsqlConnection> (fun sp ->
|
||||||
|
new NpgsqlConnection (cfg.GetConnectionString "PostgreSQL"))
|
||||||
|
let _ = builder.Services.AddScoped<IData, PostgresData> ()
|
||||||
|
let _ =
|
||||||
|
builder.Services.AddSingleton<IDistributedCache> (fun sp ->
|
||||||
|
Postgres.DistributedCache (cfg.GetConnectionString "PostgreSQL") :> IDistributedCache)
|
||||||
|
()
|
||||||
| _ -> ()
|
| _ -> ()
|
||||||
|
|
||||||
let _ = builder.Services.AddSession(fun opts ->
|
let _ = builder.Services.AddSession(fun opts ->
|
||||||
@@ -158,11 +184,16 @@ let rec main args =
|
|||||||
| Some it when it = "restore" -> Maintenance.Backup.restoreFromBackup args app.Services
|
| Some it when it = "restore" -> Maintenance.Backup.restoreFromBackup args app.Services
|
||||||
| Some it when it = "do-restore" -> Maintenance.Backup.restoreFromBackup args app.Services
|
| Some it when it = "do-restore" -> Maintenance.Backup.restoreFromBackup args app.Services
|
||||||
| Some it when it = "upgrade-user" -> Maintenance.upgradeUser args app.Services
|
| Some it when it = "upgrade-user" -> Maintenance.upgradeUser args app.Services
|
||||||
|
| Some it when it = "set-password" -> Maintenance.setPassword args app.Services
|
||||||
| Some it when it = "help" -> showHelp ()
|
| Some it when it = "help" -> showHelp ()
|
||||||
| Some it ->
|
| Some it ->
|
||||||
printfn $"""Unrecognized command "{it}" - valid commands are:"""
|
printfn $"""Unrecognized command "{it}" - valid commands are:"""
|
||||||
showHelp ()
|
showHelp ()
|
||||||
| None ->
|
| None -> task {
|
||||||
|
// Load all themes in the application directory
|
||||||
|
for themeFile in Directory.EnumerateFiles (".", "*-theme.zip") do
|
||||||
|
do! Maintenance.loadTheme [| ""; themeFile |] app.Services
|
||||||
|
|
||||||
let _ = app.UseForwardedHeaders ()
|
let _ = app.UseForwardedHeaders ()
|
||||||
let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict))
|
let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict))
|
||||||
let _ = app.UseMiddleware<WebLogMiddleware> ()
|
let _ = app.UseMiddleware<WebLogMiddleware> ()
|
||||||
@@ -172,7 +203,8 @@ let rec main args =
|
|||||||
let _ = app.UseSession ()
|
let _ = app.UseSession ()
|
||||||
let _ = app.UseGiraffe Handlers.Routes.endpoint
|
let _ = app.UseGiraffe Handlers.Routes.endpoint
|
||||||
|
|
||||||
Task.FromResult (app.Run ())
|
app.Run ()
|
||||||
|
}
|
||||||
|> Async.AwaitTask |> Async.RunSynchronously
|
|> Async.AwaitTask |> Async.RunSynchronously
|
||||||
|
|
||||||
0 // Exit code
|
0 // Exit code
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"Generator": "myWebLog 2.0-beta05",
|
"Generator": "myWebLog 2.0-rc2",
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"MyWebLog.Handlers": "Information"
|
"MyWebLog.Handlers": "Information"
|
||||||
|
|||||||
32
src/admin-theme/_edit-common.liquid
Normal file
32
src/admin-theme/_edit-common.liquid
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<div class="form-floating pb-3">
|
||||||
|
<input type="text" name="Title" id="title" class="form-control" placeholder="Title" autofocus required
|
||||||
|
value="{{ model.title }}">
|
||||||
|
<label for="title">Title</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-floating pb-3">
|
||||||
|
<input type="text" name="Permalink" id="permalink" class="form-control" placeholder="Permalink" required
|
||||||
|
value="{{ model.permalink }}">
|
||||||
|
<label for="permalink">Permalink</label>
|
||||||
|
{%- unless model.is_new %}
|
||||||
|
{%- assign entity_url_base = "admin/" | append: entity | append: "/" | append: entity_id -%}
|
||||||
|
<span class="form-text">
|
||||||
|
<a href="{{ entity_url_base | append: "/permalinks" | relative_link }}">Manage Permalinks</a>
|
||||||
|
<span class="text-muted"> • </span>
|
||||||
|
<a href="{{ entity_url_base | append: "/revisions" | relative_link }}">Manage Revisions</a>
|
||||||
|
</span>
|
||||||
|
{%- endunless -%}
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label for="text">Text</label>
|
||||||
|
<div class="btn-group btn-group-sm" role="group" aria-label="Text format button group">
|
||||||
|
<input type="radio" name="Source" id="source_html" class="btn-check" value="HTML"
|
||||||
|
{%- if model.source == "HTML" %} checked="checked"{% endif %}>
|
||||||
|
<label class="btn btn-sm btn-outline-secondary" for="source_html">HTML</label>
|
||||||
|
<input type="radio" name="Source" id="source_md" class="btn-check" value="Markdown"
|
||||||
|
{%- if model.source == "Markdown" %} checked="checked"{% endif %}>
|
||||||
|
<label class="btn btn-sm btn-outline-secondary" for="source_md">Markdown</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pb-3">
|
||||||
|
<textarea name="Text" id="text" class="form-control" rows="20">{{ model.text }}</textarea>
|
||||||
|
</div>
|
||||||
@@ -7,28 +7,42 @@
|
|||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="collapse navbar-collapse" id="navbarText">
|
<div class="collapse navbar-collapse" id="navbarText">
|
||||||
{% if is_logged_on -%}
|
{%- if is_logged_on %}
|
||||||
<ul class="navbar-nav">
|
<ul class="navbar-nav">
|
||||||
{{ "admin/dashboard" | nav_link: "Dashboard" }}
|
{{ "admin/dashboard" | nav_link: "Dashboard" }}
|
||||||
{% if is_author %}
|
{%- if is_author %}
|
||||||
{{ "admin/pages" | nav_link: "Pages" }}
|
{{ "admin/pages" | nav_link: "Pages" }}
|
||||||
{{ "admin/posts" | nav_link: "Posts" }}
|
{{ "admin/posts" | nav_link: "Posts" }}
|
||||||
{{ "admin/uploads" | nav_link: "Uploads" }}
|
{{ "admin/uploads" | nav_link: "Uploads" }}
|
||||||
{% endif %}
|
{%- endif %}
|
||||||
{% if is_web_log_admin %}
|
{%- if is_web_log_admin %}
|
||||||
{{ "admin/categories" | nav_link: "Categories" }}
|
{{ "admin/categories" | nav_link: "Categories" }}
|
||||||
{{ "admin/users" | nav_link: "Users" }}
|
|
||||||
{{ "admin/settings" | nav_link: "Settings" }}
|
{{ "admin/settings" | nav_link: "Settings" }}
|
||||||
{% endif %}
|
{%- endif %}
|
||||||
|
{%- if is_administrator %}
|
||||||
|
{{ "admin/administration" | nav_link: "Admin" }}
|
||||||
|
{%- endif %}
|
||||||
</ul>
|
</ul>
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
<ul class="navbar-nav flex-grow-1 justify-content-end">
|
<ul class="navbar-nav flex-grow-1 justify-content-end">
|
||||||
{% if is_logged_on -%}
|
{%- if is_logged_on %}
|
||||||
{{ "admin/user/my-info" | nav_link: "My Info" }}
|
{{ "admin/my-info" | nav_link: "My Info" }}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="https://bitbadger.solutions/open-source/myweblog/#how-to-use-myweblog"
|
||||||
|
target="_blank">
|
||||||
|
Docs
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{{ "user/log-off" | relative_link }}" hx-boost="false">Log Off</a>
|
<a class="nav-link" href="{{ "user/log-off" | relative_link }}" hx-boost="false">Log Off</a>
|
||||||
</li>
|
</li>
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="https://bitbadger.solutions/open-source/myweblog/#how-to-use-myweblog"
|
||||||
|
target="_blank">
|
||||||
|
Docs
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
{{ "user/log-on" | nav_link: "Log On" }}
|
{{ "user/log-on" | nav_link: "Log On" }}
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -36,29 +50,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main class="mx-3 mt-3">
|
<div id="toastHost" class="position-fixed top-0 w-100" aria-live="polite" aria-atomic="true">
|
||||||
<div class="messages mt-2" id="msgContainer">
|
<div id="toasts" class="toast-container position-absolute p-3 mt-5 top-0 end-0">
|
||||||
{% for msg in messages %}
|
{% for msg in messages %}
|
||||||
<div role="alert" class="alert alert-{{ msg.level }} alert-dismissible fade show">
|
<div class="toast" role="alert" aria-live="assertive" aria-atomic="true"
|
||||||
{{ msg.message }}
|
{%- unless msg.level == "success" %} data-bs-autohide="false"{% endunless %}>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<div class="toast-header bg-{{ msg.level }}{% unless msg.level == "warning" %} text-white{% endunless %}">
|
||||||
{% if msg.detail %}
|
<strong class="me-auto text-uppercase">
|
||||||
<hr>
|
{% if msg.level == "danger" %}error{% else %}{{ msg.level}}{% endif %}
|
||||||
{{ msg.detail.value }}
|
</strong>
|
||||||
{% endif %}
|
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="toast-body bg-{{ msg.level }} bg-opacity-25">
|
||||||
|
{{ msg.message }}
|
||||||
|
{%- if msg.detail %}
|
||||||
|
<hr>
|
||||||
|
{{ msg.detail.value }}
|
||||||
|
{%- endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<main class="mx-3 mt-3">
|
||||||
|
<div class="load-overlay p-5" id="loadOverlay"><h1 class="p-3">Loading…</h1></div>
|
||||||
{{ content }}
|
{{ content }}
|
||||||
</main>
|
</main>
|
||||||
<footer class="position-fixed bottom-0 w-100">
|
<footer class="position-fixed bottom-0 w-100">
|
||||||
<div class="container-fluid">
|
<div class="text-end text-white me-2">
|
||||||
<div class="row">
|
{%- assign version = generator | split: " " -%}
|
||||||
<div class="col-xs-12 text-end">
|
<small class="me-1 align-baseline">v{{ version[1] }}</small>
|
||||||
{%- assign version = generator | split: " " -%}
|
<img src="{{ "themes/admin/logo-light.png" | relative_link }}" alt="myWebLog" width="120" height="34">
|
||||||
<small class="me-1 align-baseline">v{{ version[1] }}</small>
|
|
||||||
<img src="{{ "themes/admin/logo-light.png" | relative_link }}" alt="myWebLog" width="120" height="34">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
3
src/admin-theme/_theme-list-columns.liquid
Normal file
3
src/admin-theme/_theme-list-columns.liquid
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{%- assign theme_col = "col-12 col-md-6" -%}
|
||||||
|
{%- assign slug_col = "d-none d-md-block col-md-3" -%}
|
||||||
|
{%- assign tmpl_col = "d-none d-md-block col-md-3" -%}
|
||||||
4
src/admin-theme/_user-list-columns.liquid
Normal file
4
src/admin-theme/_user-list-columns.liquid
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{%- assign user_col = "col-12 col-md-4 col-xl-3" -%}
|
||||||
|
{%- assign email_col = "col-12 col-md-4 col-xl-4" -%}
|
||||||
|
{%- assign cre8_col = "d-none d-xl-block col-xl-2" -%}
|
||||||
|
{%- assign last_col = "col-12 col-md-4 col-xl-3" -%}
|
||||||
108
src/admin-theme/admin-dashboard.liquid
Normal file
108
src/admin-theme/admin-dashboard.liquid
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<h2 class="my-3">{{ page_title }}</h2>
|
||||||
|
<article>
|
||||||
|
<fieldset class="container mb-3 pb-0">
|
||||||
|
<legend>Themes</legend>
|
||||||
|
<a href="{{ "admin/theme/new" | relative_link }}" class="btn btn-primary btn-sm mb-3"
|
||||||
|
hx-target="#theme_new">
|
||||||
|
Upload a New Theme
|
||||||
|
</a>
|
||||||
|
<div class="container g-0">
|
||||||
|
{% include_template "_theme-list-columns" %}
|
||||||
|
<div class="row mwl-table-heading">
|
||||||
|
<div class="{{ theme_col }}">Theme</div>
|
||||||
|
<div class="{{ slug_col }} d-none d-md-inline-block">Slug</div>
|
||||||
|
<div class="{{ tmpl_col }} d-none d-md-inline-block">Templates</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mwl-table-detail" id="theme_new"></div>
|
||||||
|
{{ theme_list }}
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="container mb-3 pb-0">
|
||||||
|
{%- assign cache_base_url = "admin/cache/" -%}
|
||||||
|
<legend>Caches</legend>
|
||||||
|
<div class="row pb-2">
|
||||||
|
<div class="col">
|
||||||
|
<p>
|
||||||
|
myWebLog uses a few caches to ensure that it serves pages as fast as possible.
|
||||||
|
(<a href="https://bitbadger.solutions/open-source/myweblog/advanced.html#cache-management"
|
||||||
|
target="_blank">more information</a>)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-lg-6 pb-3">
|
||||||
|
<div class="card">
|
||||||
|
<header class="card-header text-white bg-secondary">Web Logs</header>
|
||||||
|
<div class="card-body pb-0">
|
||||||
|
<h6 class="card-subtitle text-muted pb-3">
|
||||||
|
These caches include the page list and categories for each web log
|
||||||
|
</h6>
|
||||||
|
{%- assign web_log_base_url = cache_base_url | append: "web-log/" -%}
|
||||||
|
<form method="post" class="container g-0" hx-boost="false" hx-target="body"
|
||||||
|
hx-swap="innerHTML show:window:top">
|
||||||
|
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary mb-2"
|
||||||
|
hx-post="{{ web_log_base_url | append: "all/refresh" | relative_link }}">
|
||||||
|
Refresh All
|
||||||
|
</button>
|
||||||
|
<div class="row mwl-table-heading">
|
||||||
|
<div class="col">Web Log</div>
|
||||||
|
</div>
|
||||||
|
{%- for web_log in web_logs %}
|
||||||
|
<div class="row mwl-table-detail">
|
||||||
|
<div class="col">
|
||||||
|
{{ web_log[1] }}<br>
|
||||||
|
<small>
|
||||||
|
<span class="text-muted">{{ web_log[2] }}</span><br>
|
||||||
|
{%- assign refresh_url = web_log_base_url | append: web_log[0] | append: "/refresh" | relative_link -%}
|
||||||
|
<a href="{{ refresh_url }}" hx-post="{{ refresh_url }}">Refresh</a>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{%- endfor %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-6 pb-3">
|
||||||
|
<div class="card">
|
||||||
|
<header class="card-header text-white bg-secondary">Themes</header>
|
||||||
|
<div class="card-body pb-0">
|
||||||
|
<h6 class="card-subtitle text-muted pb-3">
|
||||||
|
The theme template cache is filled on demand as pages are displayed; refreshing a theme with no cached
|
||||||
|
templates will still refresh its asset cache
|
||||||
|
</h6>
|
||||||
|
{%- assign theme_base_url = cache_base_url | append: "theme/" -%}
|
||||||
|
<form method="post" class="container g-0" hx-boost="false" hx-target="body"
|
||||||
|
hx-swap="innerHTML show:window:top">
|
||||||
|
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary mb-2"
|
||||||
|
hx-post="{{ theme_base_url | append: "all/refresh" | relative_link }}">
|
||||||
|
Refresh All
|
||||||
|
</button>
|
||||||
|
<div class="row mwl-table-heading">
|
||||||
|
<div class="col-8">Theme</div>
|
||||||
|
<div class="col-4">Cached</div>
|
||||||
|
</div>
|
||||||
|
{%- for theme in cached_themes %}
|
||||||
|
{% unless theme[0] == "admin" %}
|
||||||
|
<div class="row mwl-table-detail">
|
||||||
|
<div class="col-8">
|
||||||
|
{{ theme[1] }}<br>
|
||||||
|
<small>
|
||||||
|
<span class="text-muted">{{ theme[0] }} • </span>
|
||||||
|
{%- assign refresh_url = theme_base_url | append: theme[0] | append: "/refresh" | relative_link -%}
|
||||||
|
<a href="{{ refresh_url }}" hx-post="{{ refresh_url }}">Refresh</a>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">{{ theme[2] }}</div>
|
||||||
|
</div>
|
||||||
|
{% endunless %}
|
||||||
|
{%- endfor %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</article>
|
||||||
@@ -7,21 +7,21 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12 col-sm-6 col-lg-4 col-xxl-3 offset-xxl-1 mb-3">
|
<div class="col-12 col-sm-6 col-lg-4 col-xxl-3 offset-xxl-1 mb-3">
|
||||||
<div class="form-floating">
|
<div class="form-floating">
|
||||||
<input type="text" name="Name" id="name" class="form-control form-control-sm" placeholder="Name" autofocus
|
<input type="text" name="Name" id="name" class="form-control" placeholder="Name" autofocus required
|
||||||
required value="{{ model.name | escape }}">
|
value="{{ model.name | escape }}">
|
||||||
<label for="name">Name</label>
|
<label for="name">Name</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-sm-6 col-lg-4 col-xxl-3 mb-3">
|
<div class="col-12 col-sm-6 col-lg-4 col-xxl-3 mb-3">
|
||||||
<div class="form-floating">
|
<div class="form-floating">
|
||||||
<input type="text" name="Slug" id="slug" class="form-control form-control-sm" placeholder="Slug" required
|
<input type="text" name="Slug" id="slug" class="form-control" placeholder="Slug" required
|
||||||
value="{{ model.slug | escape }}">
|
value="{{ model.slug | escape }}">
|
||||||
<label for="slug">Slug</label>
|
<label for="slug">Slug</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-lg-4 col-xxl-3 offset-xxl-1 mb-3">
|
<div class="col-12 col-lg-4 col-xxl-3 offset-xxl-1 mb-3">
|
||||||
<div class="form-floating">
|
<div class="form-floating">
|
||||||
<select name="ParentId" id="parentId" class="form-control form-control-sm">
|
<select name="ParentId" id="parentId" class="form-control">
|
||||||
<option value=""{% if model.parent_id == "" %} selected="selected"{% endif %}>
|
<option value=""{% if model.parent_id == "" %} selected="selected"{% endif %}>
|
||||||
– None –
|
– None –
|
||||||
</option>
|
</option>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-xl-10 offset-xl-1 mb-3">
|
<div class="col-12 col-xl-10 offset-xl-1 mb-3">
|
||||||
<div class="form-floating">
|
<div class="form-floating">
|
||||||
<input name="Description" id="description" class="form-control form-control-sm"
|
<input name="Description" id="description" class="form-control"
|
||||||
placeholder="A short description of this category" value="{{ model.description | escape }}">
|
placeholder="A short description of this category" value="{{ model.description | escape }}">
|
||||||
<label for="description">Description</label>
|
<label for="description">Description</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,45 +1,57 @@
|
|||||||
<form method="post" id="catList" class="container" hx-target="this" hx-swap="outerHTML show:window:top">
|
<div id="catList" class="container">
|
||||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
<div class="row">
|
||||||
<div class="row mwl-table-detail" id="cat_new"></div>
|
<div class="col">
|
||||||
{%- assign cat_count = categories | size -%}
|
{%- assign cat_count = categories | size -%}
|
||||||
{% if cat_count > 0 %}
|
{% if cat_count > 0 %}
|
||||||
{%- assign cat_col = "col-12 col-md-6 col-xl-5 col-xxl-4" -%}
|
{%- assign cat_col = "col-12 col-md-6 col-xl-5 col-xxl-4" -%}
|
||||||
{%- assign desc_col = "col-12 col-md-6 col-xl-7 col-xxl-8" -%}
|
{%- assign desc_col = "col-12 col-md-6 col-xl-7 col-xxl-8" -%}
|
||||||
{% for cat in categories -%}
|
<div class="container">
|
||||||
<div class="row mwl-table-detail" id="cat_{{ cat.id }}">
|
<div class="row mwl-table-heading">
|
||||||
<div class="{{ cat_col }} no-wrap">
|
<div class="{{ cat_col }}">Category<span class="d-md-none">; Description</span></div>
|
||||||
{%- if cat.parent_names %}
|
<div class="{{ desc_col }} d-none d-md-inline-block">Description</div>
|
||||||
<small class="text-muted">{% for name in cat.parent_names %}{{ name }} ⟩ {% endfor %}</small>
|
</div>
|
||||||
{%- endif %}
|
|
||||||
{{ cat.name }}<br>
|
|
||||||
<small>
|
|
||||||
{%- assign cat_url_base = "admin/category/" | append: cat.id -%}
|
|
||||||
{%- if cat.post_count > 0 %}
|
|
||||||
<a href="{{ cat | category_link }}" target="_blank">
|
|
||||||
View {{ cat.post_count }} Post{% unless cat.post_count == 1 %}s{% endunless -%}
|
|
||||||
</a>
|
|
||||||
<span class="text-muted"> • </span>
|
|
||||||
{%- endif %}
|
|
||||||
<a href="{{ cat_url_base | append: "/edit" | relative_link }}" hx-target="#cat_{{ cat.id }}"
|
|
||||||
hx-swap="innerHTML show:#cat_{{ cat.id }}:top">
|
|
||||||
Edit
|
|
||||||
</a>
|
|
||||||
<span class="text-muted"> • </span>
|
|
||||||
{%- assign cat_del_link = cat_url_base | append: "/delete" | relative_link -%}
|
|
||||||
<a href="{{ cat_del_link }}" hx-post="{{ cat_del_link }}" class="text-danger"
|
|
||||||
hx-confirm="Are you sure you want to delete the category “{{ cat.name }}”? This action cannot be undone.">
|
|
||||||
Delete
|
|
||||||
</a>
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="{{ desc_col }}">
|
<form method="post" class="container" hx-target="#catList" hx-swap="outerHTML show:window:top">
|
||||||
{%- if cat.description %}{{ cat.description.value }}{% else %}<em class="text-muted">none</em>{% endif %}
|
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||||
|
<div class="row mwl-table-detail" id="cat_new"></div>
|
||||||
|
{% for cat in categories -%}
|
||||||
|
<div class="row mwl-table-detail" id="cat_{{ cat.id }}">
|
||||||
|
<div class="{{ cat_col }} no-wrap">
|
||||||
|
{%- if cat.parent_names %}
|
||||||
|
<small class="text-muted">{% for name in cat.parent_names %}{{ name }} ⟩ {% endfor %}</small>
|
||||||
|
{%- endif %}
|
||||||
|
{{ cat.name }}<br>
|
||||||
|
<small>
|
||||||
|
{%- assign cat_url_base = "admin/category/" | append: cat.id -%}
|
||||||
|
{%- if cat.post_count > 0 %}
|
||||||
|
<a href="{{ cat | category_link }}" target="_blank">
|
||||||
|
View {{ cat.post_count }} Post{% unless cat.post_count == 1 %}s{% endunless -%}
|
||||||
|
</a>
|
||||||
|
<span class="text-muted"> • </span>
|
||||||
|
{%- endif %}
|
||||||
|
<a href="{{ cat_url_base | append: "/edit" | relative_link }}" hx-target="#cat_{{ cat.id }}"
|
||||||
|
hx-swap="innerHTML show:#cat_{{ cat.id }}:top">
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
<span class="text-muted"> • </span>
|
||||||
|
{%- assign cat_del_link = cat_url_base | append: "/delete" | relative_link -%}
|
||||||
|
<a href="{{ cat_del_link }}" hx-post="{{ cat_del_link }}" class="text-danger"
|
||||||
|
hx-confirm="Are you sure you want to delete the category “{{ cat.name }}”? This action cannot be undone.">
|
||||||
|
Delete
|
||||||
|
</a>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="{{ desc_col }}">
|
||||||
|
{%- if cat.description %}{{ cat.description.value }}{% else %}<em class="text-muted">none</em>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{%- endfor %}
|
||||||
|
</form>
|
||||||
|
{%- else -%}
|
||||||
|
<div id="cat_new">
|
||||||
|
<p class="text-muted fst-italic text-center">This web log has no categores defined</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{%- endif %}
|
||||||
{%- endfor %}
|
|
||||||
{%- else -%}
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12 text-muted fst-italic text-center">This web log has no categores defined</div>
|
|
||||||
</div>
|
</div>
|
||||||
{%- endif %}
|
</div>
|
||||||
</form>
|
</div>
|
||||||
|
|||||||
@@ -4,13 +4,5 @@
|
|||||||
hx-target="#cat_new">
|
hx-target="#cat_new">
|
||||||
Add a New Category
|
Add a New Category
|
||||||
</a>
|
</a>
|
||||||
<div class="container">
|
|
||||||
{%- assign cat_col = "col-12 col-md-6 col-xl-5 col-xxl-4" -%}
|
|
||||||
{%- assign desc_col = "col-12 col-md-6 col-xl-7 col-xxl-8" -%}
|
|
||||||
<div class="row mwl-table-heading">
|
|
||||||
<div class="{{ cat_col }}">Category<span class="d-md-none">; Description</span></div>
|
|
||||||
<div class="{{ desc_col }} d-none d-md-inline-block">Description</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{ category_list }}
|
{{ category_list }}
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -5,6 +5,5 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% include_template "_layout" %}
|
{% include_template "_layout" %}
|
||||||
<script>Admin.dismissSuccesses()</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,29 +4,16 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="generator" content="{{ generator }}">
|
<meta name="generator" content="{{ generator }}">
|
||||||
<title>{{ page_title | strip_html }} « Admin « {{ web_log.name | strip_html }}</title>
|
<title>{{ page_title | strip_html }} « Admin « {{ web_log.name | strip_html }}</title>
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
|
||||||
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
|
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
|
||||||
<link rel="stylesheet" href="{{ "themes/admin/admin.css" | relative_link }}">
|
<link rel="stylesheet" href="{{ "themes/admin/admin.css" | relative_link }}">
|
||||||
</head>
|
</head>
|
||||||
<body hx-boost="true">
|
<body hx-boost="true" hx-indicator="#loadOverlay">
|
||||||
{% include_template "_layout" %}
|
{% include_template "_layout" %}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"
|
||||||
integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
|
integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
{{ htmx_script }}
|
{{ htmx_script }}
|
||||||
<script>
|
|
||||||
const cssLoaded = [...document.styleSheets].filter(it => it.href.indexOf("bootstrap.min.css") > -1).length > 0
|
|
||||||
if (!cssLoaded) {
|
|
||||||
const local = document.createElement("link")
|
|
||||||
local.rel = "stylesheet"
|
|
||||||
local.href = "{{ "themes/admin/bootstrap.min.css" | relative_link }}"
|
|
||||||
document.getElementsByTagName("link")[0].prepend(local)
|
|
||||||
}
|
|
||||||
setTimeout(function () {
|
|
||||||
if (!bootstrap) document.write('<script src=\"{{ "script/bootstrap.bundle.min.js" | relative_link }}\"><\/script>')
|
|
||||||
}, 2000)
|
|
||||||
</script>
|
|
||||||
<script src="{{ "themes/admin/admin.js" | relative_link }}"></script>
|
<script src="{{ "themes/admin/admin.js" | relative_link }}"></script>
|
||||||
<script>Admin.dismissSuccesses()</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<h2 class="my-3">{{ page_title }}</h2>
|
<h2 class="my-3">{{ page_title }}</h2>
|
||||||
<article>
|
<article>
|
||||||
<form action="{{ "admin/user/my-info" | relative_link }}" method="post">
|
<form action="{{ "admin/my-info" | relative_link }}" method="post">
|
||||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||||
<div class="d-flex flex-row flex-wrap justify-content-around">
|
<div class="d-flex flex-row flex-wrap justify-content-around">
|
||||||
<div class="text-center mb-3 lh-sm">
|
<div class="text-center mb-3 lh-sm">
|
||||||
|
|||||||
@@ -6,39 +6,9 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-9">
|
<div class="col-9">
|
||||||
<div class="form-floating pb-3">
|
{%- assign entity = "page" -%}
|
||||||
<input type="text" name="Title" id="title" class="form-control" autofocus required
|
{%- assign entity_id = model.page_id -%}
|
||||||
value="{{ model.title }}">
|
{% include_template "_edit-common" %}
|
||||||
<label for="title">Title</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-floating pb-3">
|
|
||||||
<input type="text" name="Permalink" id="permalink" class="form-control" required
|
|
||||||
value="{{ model.permalink }}">
|
|
||||||
<label for="permalink">Permalink</label>
|
|
||||||
{%- unless model.is_new %}
|
|
||||||
<span class="form-text">
|
|
||||||
<a href="{{ "admin/page/" | append: model.page_id | append: "/permalinks" | relative_link }}">
|
|
||||||
Manage Permalinks
|
|
||||||
</a>
|
|
||||||
<span class="text-muted"> • </span>
|
|
||||||
<a href="{{ "admin/page/" | append: model.page_id | append: "/revisions" | relative_link }}">
|
|
||||||
Manage Revisions
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
{% endunless -%}
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<label for="text">Text</label>
|
|
||||||
<input type="radio" name="Source" id="source_html" class="btn-check" value="HTML"
|
|
||||||
{%- if model.source == "HTML" %} checked="checked"{% endif %}>
|
|
||||||
<label class="btn btn-sm btn-outline-secondary" for="source_html">HTML</label>
|
|
||||||
<input type="radio" name="Source" id="source_md" class="btn-check" value="Markdown"
|
|
||||||
{%- if model.source == "Markdown" %} checked="checked"{% endif %}>
|
|
||||||
<label class="btn btn-sm btn-outline-secondary" for="source_md">Markdown</label>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<textarea name="Text" id="text" class="form-control">{{ model.text }}</textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-3">
|
<div class="col-3">
|
||||||
<div class="form-floating pb-3">
|
<div class="form-floating pb-3">
|
||||||
|
|||||||
@@ -2,19 +2,19 @@
|
|||||||
<article>
|
<article>
|
||||||
<a href="{{ "admin/page/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3">Create a New Page</a>
|
<a href="{{ "admin/page/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3">Create a New Page</a>
|
||||||
{%- assign page_count = pages | size -%}
|
{%- assign page_count = pages | size -%}
|
||||||
{%- assign title_col = "col-12 col-md-5" -%}
|
{% if page_count > 0 %}
|
||||||
{%- assign link_col = "col-12 col-md-5" -%}
|
{%- assign title_col = "col-12 col-md-5" -%}
|
||||||
{%- assign upd8_col = "col-12 col-md-2" -%}
|
{%- assign link_col = "col-12 col-md-5" -%}
|
||||||
<form method="post" class="container" hx-target="body">
|
{%- assign upd8_col = "col-12 col-md-2" -%}
|
||||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
<form method="post" class="container" hx-target="body">
|
||||||
<div class="row mwl-table-heading">
|
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||||
<div class="{{ title_col }}">
|
<div class="row mwl-table-heading">
|
||||||
<span class="d-none d-md-inline">Title</span><span class="d-md-none">Page</span>
|
<div class="{{ title_col }}">
|
||||||
|
<span class="d-none d-md-inline">Title</span><span class="d-md-none">Page</span>
|
||||||
|
</div>
|
||||||
|
<div class="{{ link_col }} d-none d-md-inline-block">Permalink</div>
|
||||||
|
<div class="{{ upd8_col }} d-none d-md-inline-block">Updated</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="{{ link_col }} d-none d-md-inline-block">Permalink</div>
|
|
||||||
<div class="{{ upd8_col }} d-none d-md-inline-block">Updated</div>
|
|
||||||
</div>
|
|
||||||
{% if page_count > 0 %}
|
|
||||||
{% for pg in pages -%}
|
{% for pg in pages -%}
|
||||||
<div class="row mwl-table-detail">
|
<div class="row mwl-table-detail">
|
||||||
<div class="{{ title_col }}">
|
<div class="{{ title_col }}">
|
||||||
@@ -48,30 +48,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
{% else %}
|
</form>
|
||||||
<div class="row">
|
{% if page_nbr > 1 or page_count == 25 %}
|
||||||
<div class="col text-muted fst-italic text-center">This web log has no pages</div>
|
<div class="d-flex justify-content-evenly mb-3">
|
||||||
|
<div>
|
||||||
|
{% if page_nbr > 1 %}
|
||||||
|
<p>
|
||||||
|
<a class="btn btn-secondary" href="{{ "admin/pages" | append: prev_page | relative_link }}">
|
||||||
|
« Previous
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
{% if page_count == 25 %}
|
||||||
|
<p>
|
||||||
|
<a class="btn btn-secondary" href="{{ "admin/pages" | append: next_page | relative_link }}">
|
||||||
|
Next »
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</form>
|
{% else %}
|
||||||
{% if page_nbr > 1 or page_count == 25 %}
|
<p class="text-muted fst-italic text-center">This web log has no pages</p>
|
||||||
<div class="d-flex justify-content-evenly pb-3">
|
|
||||||
<div>
|
|
||||||
{% if page_nbr > 1 %}
|
|
||||||
<p>
|
|
||||||
<a class="btn btn-default" href="{{ "admin/pages" | append: prev_page | relative_link }}">
|
|
||||||
« Previous
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="text-right">
|
|
||||||
{% if page_count == 25 %}
|
|
||||||
<p>
|
|
||||||
<a class="btn btn-default" href="{{ "admin/pages" | append: next_page | relative_link }}">Next »</a>
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -6,41 +6,9 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-12 col-lg-9">
|
<div class="col-12 col-lg-9">
|
||||||
<div class="form-floating pb-3">
|
{%- assign entity = "post" -%}
|
||||||
<input type="text" name="Title" id="title" class="form-control" placeholder="Title" autofocus required
|
{%- assign entity_id = model.post_id -%}
|
||||||
value="{{ model.title }}">
|
{% include_template "_edit-common" %}
|
||||||
<label for="title">Title</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-floating pb-3">
|
|
||||||
<input type="text" name="Permalink" id="permalink" class="form-control" placeholder="Permalink" required
|
|
||||||
value="{{ model.permalink }}">
|
|
||||||
<label for="permalink">Permalink</label>
|
|
||||||
{%- unless model.is_new %}
|
|
||||||
<span class="form-text">
|
|
||||||
<a href="{{ "admin/post/" | append: model.post_id | append: "/permalinks" | relative_link }}">
|
|
||||||
Manage Permalinks
|
|
||||||
</a>
|
|
||||||
<span class="text-muted"> • </span>
|
|
||||||
<a href="{{ "admin/post/" | append: model.post_id | append: "/revisions" | relative_link }}">
|
|
||||||
Manage Revisions
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
{% endunless -%}
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<label for="text">Text</label>
|
|
||||||
<div class="btn-group btn-group-sm" role="group" aria-label="Text format button group">
|
|
||||||
<input type="radio" name="Source" id="source_html" class="btn-check" value="HTML"
|
|
||||||
{%- if model.source == "HTML" %} checked="checked"{% endif %}>
|
|
||||||
<label class="btn btn-sm btn-outline-secondary" for="source_html">HTML</label>
|
|
||||||
<input type="radio" name="Source" id="source_md" class="btn-check" value="Markdown"
|
|
||||||
{%- if model.source == "Markdown" %} checked="checked"{% endif %}>
|
|
||||||
<label class="btn btn-sm btn-outline-secondary" for="source_md">Markdown</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="pb-3">
|
|
||||||
<textarea name="Text" id="text" class="form-control" rows="20">{{ model.text }}</textarea>
|
|
||||||
</div>
|
|
||||||
<div class="form-floating pb-3">
|
<div class="form-floating pb-3">
|
||||||
<input type="text" name="Tags" id="tags" class="form-control" placeholder="Tags"
|
<input type="text" name="Tags" id="tags" class="form-control" placeholder="Tags"
|
||||||
value="{{ model.tags }}">
|
value="{{ model.tags }}">
|
||||||
@@ -61,7 +29,7 @@
|
|||||||
<small>
|
<small>
|
||||||
<input type="checkbox" name="IsEpisode" id="isEpisode" class="form-check-input" value="true"
|
<input type="checkbox" name="IsEpisode" id="isEpisode" class="form-check-input" value="true"
|
||||||
data-bs-toggle="collapse" data-bs-target="#episodeItems" onclick="Admin.toggleEpisodeFields()"
|
data-bs-toggle="collapse" data-bs-target="#episodeItems" onclick="Admin.toggleEpisodeFields()"
|
||||||
{%- if model.is_episode %}checked="checked"{% endif %}>
|
{%- if model.is_episode %} checked="checked"{% endif %}>
|
||||||
</small>
|
</small>
|
||||||
<label for="isEpisode">Podcast Episode</label>
|
<label for="isEpisode">Podcast Episode</label>
|
||||||
</span>
|
</span>
|
||||||
@@ -344,3 +312,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
|
<script>window.setTimeout(() => Admin.toggleEpisodeFields(), 500)</script>
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
<h2 class="my-3">{{ page_title }}</h2>
|
<h2 class="my-3">{{ page_title }}</h2>
|
||||||
<article>
|
<article>
|
||||||
<a href="{{ "admin/post/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3">Write a New Post</a>
|
<a href="{{ "admin/post/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3">Write a New Post</a>
|
||||||
<form method="post" class="container" hx-target="body">
|
{%- assign post_count = model.posts | size -%}
|
||||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
{%- if post_count > 0 %}
|
||||||
{%- assign post_count = model.posts | size -%}
|
<form method="post" class="container" hx-target="body">
|
||||||
{%- assign date_col = "col-xs-12 col-md-3 col-lg-2" -%}
|
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||||
{%- assign title_col = "col-xs-12 col-md-7 col-lg-6 col-xl-5 col-xxl-4" -%}
|
{%- assign date_col = "col-xs-12 col-md-3 col-lg-2" -%}
|
||||||
{%- assign author_col = "col-xs-12 col-md-2 col-lg-1" -%}
|
{%- assign title_col = "col-xs-12 col-md-7 col-lg-6 col-xl-5 col-xxl-4" -%}
|
||||||
{%- assign tag_col = "col-lg-3 col-xl-4 col-xxl-5 d-none d-lg-inline-block" -%}
|
{%- assign author_col = "col-xs-12 col-md-2 col-lg-1" -%}
|
||||||
<div class="row mwl-table-heading">
|
{%- assign tag_col = "col-lg-3 col-xl-4 col-xxl-5 d-none d-lg-inline-block" -%}
|
||||||
<div class="{{ date_col }}">
|
<div class="row mwl-table-heading">
|
||||||
<span class="d-md-none">Post</span><span class="d-none d-md-inline">Date</span>
|
<div class="{{ date_col }}">
|
||||||
|
<span class="d-md-none">Post</span><span class="d-none d-md-inline">Date</span>
|
||||||
|
</div>
|
||||||
|
<div class="{{ title_col }} d-none d-md-inline-block">Title</div>
|
||||||
|
<div class="{{ author_col }} d-none d-md-inline-block">Author</div>
|
||||||
|
<div class="{{ tag_col }}">Tags</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="{{ title_col }} d-none d-md-inline-block">Title</div>
|
|
||||||
<div class="{{ author_col }} d-none d-md-inline-block">Author</div>
|
|
||||||
<div class="{{ tag_col }}">Tags</div>
|
|
||||||
</div>
|
|
||||||
{%- if post_count > 0 %}
|
|
||||||
{% for post in model.posts -%}
|
{% for post in model.posts -%}
|
||||||
<div class="row mwl-table-detail">
|
<div class="row mwl-table-detail">
|
||||||
<div class="{{ date_col }} no-wrap">
|
<div class="{{ date_col }} no-wrap">
|
||||||
@@ -77,24 +77,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
{% else %}
|
</form>
|
||||||
<div class="row">
|
{% if model.newer_link or model.older_link %}
|
||||||
<div class="col text-muted fst-italic text-center">This web log has no posts</div>
|
<div class="d-flex justify-content-evenly mb-3">
|
||||||
|
<div>
|
||||||
|
{% if model.newer_link %}
|
||||||
|
<p><a class="btn btn-secondary" href="{{ model.newer_link.value }}">« Newer Posts</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
{% if model.older_link %}
|
||||||
|
<p><a class="btn btn-secondary" href="{{ model.older_link.value }}">Older Posts »</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</form>
|
{% else %}
|
||||||
{% if model.newer_link or model.older_link %}
|
<p class="text-muted fst-italic text-center">This web log has no posts</p>
|
||||||
<div class="d-flex justify-content-evenly">
|
|
||||||
<div>
|
|
||||||
{% if model.newer_link %}
|
|
||||||
<p><a class="btn btn-default" href="{{ model.newer_link.value }}">« Newer Posts</a></p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="text-right">
|
|
||||||
{% if model.older_link %}
|
|
||||||
<p><a class="btn btn-default" href="{{ model.older_link.value }}">Older Posts »</a></p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
<h2 class="my-3">{{ page_title }}</h2>
|
|
||||||
<article>
|
|
||||||
<form action="{{ "admin/settings/rss" | relative_link }}" method="post">
|
|
||||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
|
||||||
<div class="container">
|
|
||||||
<div class="row pb-3">
|
|
||||||
<div class="col col-xl-8 offset-xl-2">
|
|
||||||
<fieldset class="d-flex justify-content-evenly flex-row">
|
|
||||||
<legend>Feeds Enabled</legend>
|
|
||||||
<div class="form-check form-switch pb-2">
|
|
||||||
<input type="checkbox" name="IsFeedEnabled" id="feedEnabled" class="form-check-input" value="true"
|
|
||||||
{%- if model.is_feed_enabled %} checked="checked"{% endif %}>
|
|
||||||
<label for="feedEnabled" class="form-check-label">All Posts</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check form-switch pb-2">
|
|
||||||
<input type="checkbox" name="IsCategoryEnabled" id="categoryEnabled" class="form-check-input" value="true"
|
|
||||||
{%- if model.is_category_enabled %} checked="checked"{% endif %}>
|
|
||||||
<label for="categoryEnabled" class="form-check-label">Posts by Category</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check form-switch pb-2">
|
|
||||||
<input type="checkbox" name="IsTagEnabled" id="tagEnabled" class="form-check-input" value="true"
|
|
||||||
{%- if model.tag_enabled %} checked="checked"{% endif %}>
|
|
||||||
<label for="tagEnabled" class="form-check-label">Posts by Tag</label>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12 col-sm-6 col-md-3 col-xl-2 offset-xl-2 pb-3">
|
|
||||||
<div class="form-floating">
|
|
||||||
<input type="text" name="FeedName" id="feedName" class="form-control" placeholder="Feed File Name"
|
|
||||||
value="{{ model.feed_name }}">
|
|
||||||
<label for="feedName">Feed File Name</label>
|
|
||||||
<span class="form-text">Default is <code>feed.xml</code></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-sm-6 col-md-4 col-xl-2 pb-3">
|
|
||||||
<div class="form-floating">
|
|
||||||
<input type="number" name="ItemsInFeed" id="itemsInFeed" class="form-control" min="0"
|
|
||||||
placeholder="Items in Feed" required value="{{ model.items_in_feed }}">
|
|
||||||
<label for="itemsInFeed">Items in Feed</label>
|
|
||||||
<span class="form-text">Set to “0” to use “Posts per Page” setting ({{ web_log.posts_per_page }})</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-md-5 col-xl-4 pb-3">
|
|
||||||
<div class="form-floating">
|
|
||||||
<input type="text" name="Copyright" id="copyright" class="form-control" placeholder="Copyright String"
|
|
||||||
value="{{ model.copyright }}">
|
|
||||||
<label for="copyright">Copyright String</label>
|
|
||||||
<span class="form-text">
|
|
||||||
Can be a
|
|
||||||
<a href="https://creativecommons.org/share-your-work/" target="_blank" rel="noopener">
|
|
||||||
Creative Commons license string
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row pb-3">
|
|
||||||
<div class="col text-center">
|
|
||||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<h3>Custom Feeds</h3>
|
|
||||||
<a class="btn btn-sm btn-secondary" href="{{ 'admin/settings/rss/new/edit' | relative_link }}">
|
|
||||||
Add a New Custom Feed
|
|
||||||
</a>
|
|
||||||
<form method="post" class="container" hx-target="body">
|
|
||||||
{%- assign source_col = "col-12 col-md-6" -%}
|
|
||||||
{%- assign path_col = "col-12 col-md-6" -%}
|
|
||||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
|
||||||
<div class="row mwl-table-heading">
|
|
||||||
<div class="{{ source_col }}">
|
|
||||||
<span class="d-md-none">Feed</span><span class="d-none d-md-inline">Source</span>
|
|
||||||
</div>
|
|
||||||
<div class="{{ path_col }} d-none d-md-inline-block">Relative Path</div>
|
|
||||||
</div>
|
|
||||||
{%- assign feed_count = custom_feeds | size -%}
|
|
||||||
{% if feed_count > 0 %}
|
|
||||||
{% for feed in custom_feeds %}
|
|
||||||
<div class="row mwl-table-detail">
|
|
||||||
<div class="{{ source_col }}">
|
|
||||||
{{ feed.source }}
|
|
||||||
{%- if feed.is_podcast %} <span class="badge bg-primary">PODCAST</span>{% endif %}<br>
|
|
||||||
<small>
|
|
||||||
{%- assign feed_url = "admin/settings/rss/" | append: feed.id -%}
|
|
||||||
<a href="{{ feed.path | relative_link }}" target="_blank">View Feed</a>
|
|
||||||
<span class="text-muted"> • </span>
|
|
||||||
<a href="{{ feed_url | append: "/edit" | relative_link }}">Edit</a>
|
|
||||||
<span class="text-muted"> • </span>
|
|
||||||
{%- assign feed_del_link = feed_url | append: "/delete" | relative_link -%}
|
|
||||||
<a href="{{ feed_del_link }}" hx-post="{{ feed_del_link }}" class="text-danger"
|
|
||||||
hx-confirm="Are you sure you want to delete the custom RSS feed based on {{ feed.source | strip_html | escape }}? This action cannot be undone.">
|
|
||||||
Delete
|
|
||||||
</a>
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div class="{{ path_col }}">
|
|
||||||
<small class="d-md-none">Served at {{ feed.path }}</small>
|
|
||||||
<span class="d-none d-md-inline">{{ feed.path }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="3" class="text-muted fst-italic text-center">No custom feeds defined</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
</form>
|
|
||||||
</article>
|
|
||||||
@@ -1,106 +1,246 @@
|
|||||||
<h2 class="my-3">{{ web_log.name }} Settings</h2>
|
<h2 class="my-3">{{ web_log.name }} Settings</h2>
|
||||||
<p class="text-muted">
|
|
||||||
Other Settings: <a href="{{ "admin/settings/tag-mappings" | relative_link }}">Tag Mappings</a> •
|
|
||||||
<a href="{{ "admin/settings/rss" | relative_link }}">RSS Settings</a>
|
|
||||||
</p>
|
|
||||||
<article>
|
<article>
|
||||||
<form action="{{ "admin/settings" | relative_link }}" method="post">
|
<p class="text-muted">
|
||||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
Go to: <a href="#users">Users</a> • <a href="#rss-settings">RSS Settings</a> •
|
||||||
<div class="container">
|
<a href="#tag-mappings">Tag Mappings</a>
|
||||||
<div class="row">
|
</p>
|
||||||
<div class="col-12 col-md-6 col-xl-4 pb-3">
|
<fieldset class="container mb-3">
|
||||||
<div class="form-floating">
|
<legend>Web Log Settings</legend>
|
||||||
<input type="text" name="Name" id="name" class="form-control" placeholder="Name" required autofocus
|
<form action="{{ "admin/settings" | relative_link }}" method="post">
|
||||||
value="{{ model.name }}">
|
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||||
<label for="name">Name</label>
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 pb-3">
|
||||||
|
<div class="form-floating">
|
||||||
|
<input type="text" name="Name" id="name" class="form-control" placeholder="Name" required autofocus
|
||||||
|
value="{{ model.name }}">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 pb-3">
|
||||||
|
<div class="form-floating">
|
||||||
|
<input type="text" name="Slug" id="slug" class="form-control" placeholder="Slug" required
|
||||||
|
value="{{ model.slug }}">
|
||||||
|
<label for="slug">Slug</label>
|
||||||
|
<span class="form-text">
|
||||||
|
<span class="badge rounded-pill bg-warning text-dark">WARNING</span> changing this value may break
|
||||||
|
links
|
||||||
|
(<a href="https://bitbadger.solutions/open-source/myweblog/configuring.html#blog-settings"
|
||||||
|
target="_blank">more</a>)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 pb-3">
|
||||||
|
<div class="form-floating">
|
||||||
|
<input type="text" name="Subtitle" id="subtitle" class="form-control" placeholder="Subtitle"
|
||||||
|
value="{{ model.subtitle }}">
|
||||||
|
<label for="subtitle">Subtitle</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 offset-xl-1 pb-3">
|
||||||
|
<div class="form-floating">
|
||||||
|
<select name="ThemeId" id="themeId" class="form-control" required>
|
||||||
|
{% for theme in themes -%}
|
||||||
|
<option value="{{ theme[0] }}"{% if model.theme_id == theme[0] %} selected="selected"{% endif %}>
|
||||||
|
{{ theme[1] }}
|
||||||
|
</option>
|
||||||
|
{%- endfor %}
|
||||||
|
</select>
|
||||||
|
<label for="themeId">Theme</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 offset-md-1 col-xl-4 offset-xl-0 pb-3">
|
||||||
|
<div class="form-floating">
|
||||||
|
<select name="DefaultPage" id="defaultPage" class="form-control" required>
|
||||||
|
{%- for pg in pages %}
|
||||||
|
<option value="{{ pg[0] }}"{% if pg[0] == model.default_page %} selected="selected"{% endif %}>
|
||||||
|
{{ pg[1] }}
|
||||||
|
</option>
|
||||||
|
{%- endfor %}
|
||||||
|
</select>
|
||||||
|
<label for="defaultPage">Default Page</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4 col-xl-2 pb-3">
|
||||||
|
<div class="form-floating">
|
||||||
|
<input type="number" name="PostsPerPage" id="postsPerPage" class="form-control" min="0" max="50" required
|
||||||
|
value="{{ model.posts_per_page }}">
|
||||||
|
<label for="postsPerPage">Posts per Page</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6 col-xl-4 pb-3">
|
<div class="row">
|
||||||
<div class="form-floating">
|
<div class="col-12 col-md-4 col-xl-3 offset-xl-2 pb-3">
|
||||||
<input type="text" name="Slug" id="slug" class="form-control" placeholder="Slug" required
|
<div class="form-floating">
|
||||||
value="{{ model.slug }}">
|
<input type="text" name="TimeZone" id="timeZone" class="form-control" placeholder="Time Zone" required
|
||||||
<label for="slug">Slug</label>
|
value="{{ model.time_zone }}">
|
||||||
<span class="form-text">
|
<label for="timeZone">Time Zone</label>
|
||||||
<span class="badge rounded-pill bg-warning text-dark">WARNING</span> changing this value may break links
|
</div>
|
||||||
(<a href="https://bitbadger.solutions/open-source/myweblog/configuring.html#blog-settings"
|
</div>
|
||||||
target="_blank">more</a>)
|
<div class="col-12 col-md-4 col-xl-2">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" name="AutoHtmx" id="autoHtmx" class="form-check-input" value="true"
|
||||||
|
{%- if model.auto_htmx %} checked="checked"{% endif %}>
|
||||||
|
<label for="autoHtmx" class="form-check-label">Auto-Load htmx</label>
|
||||||
|
</div>
|
||||||
|
<span class="form-text fst-italic">
|
||||||
|
<a href="https://htmx.org" target="_blank" rel="noopener">What is this?</a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="col-12 col-md-4 col-xl-3 pb-3">
|
||||||
<div class="col-12 col-md-6 col-xl-4 pb-3">
|
<div class="form-floating">
|
||||||
<div class="form-floating">
|
<select name="Uploads" id="uploads" class="form-control">
|
||||||
<input type="text" name="Subtitle" id="subtitle" class="form-control" placeholder="Subtitle"
|
{%- for it in upload_values %}
|
||||||
value="{{ model.subtitle }}">
|
<option value="{{ it[0] }}"{% if model.uploads == it[0] %} selected{% endif %}>{{ it[1] }}</option>
|
||||||
<label for="subtitle">Subtitle</label>
|
{%- endfor %}
|
||||||
|
</select>
|
||||||
|
<label for="uploads">Default Upload Destination</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6 col-xl-4 offset-xl-1 pb-3">
|
<div class="row pb-3">
|
||||||
<div class="form-floating">
|
<div class="col text-center">
|
||||||
<select name="ThemeId" id="themeId" class="form-control" required>
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
{% for theme in themes -%}
|
|
||||||
<option value="{{ theme[0] }}"{% if model.theme_id == theme[0] %} selected="selected"{% endif %}>
|
|
||||||
{{ theme[1] }}
|
|
||||||
</option>
|
|
||||||
{%- endfor %}
|
|
||||||
</select>
|
|
||||||
<label for="themeId">Theme</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-md-6 offset-md-1 col-xl-4 offset-xl-0 pb-3">
|
|
||||||
<div class="form-floating">
|
|
||||||
<select name="DefaultPage" id="defaultPage" class="form-control" required>
|
|
||||||
{% for pg in pages -%}
|
|
||||||
<option value="{{ pg[0] }}"
|
|
||||||
{%- if pg[0] == model.default_page %} selected="selected"{% endif %}>
|
|
||||||
{{ pg[1] }}
|
|
||||||
</option>
|
|
||||||
{%- endfor %}
|
|
||||||
</select>
|
|
||||||
<label for="defaultPage">Default Page</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-md-4 col-xl-2 pb-3">
|
|
||||||
<div class="form-floating">
|
|
||||||
<input type="number" name="PostsPerPage" id="postsPerPage" class="form-control" min="0" max="50" required
|
|
||||||
value="{{ model.posts_per_page }}">
|
|
||||||
<label for="postsPerPage">Posts per Page</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
</form>
|
||||||
<div class="col-12 col-md-4 col-xl-3 offset-xl-2 pb-3">
|
</fieldset>
|
||||||
<div class="form-floating">
|
<fieldset id="users" class="container mb-3 pb-0">
|
||||||
<input type="text" name="TimeZone" id="timeZone" class="form-control" placeholder="Time Zone" required
|
<legend>Users</legend>
|
||||||
value="{{ model.time_zone }}">
|
{% include_template "_user-list-columns" %}
|
||||||
<label for="timeZone">Time Zone</label>
|
<a href="{{ "admin/settings/user/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3"
|
||||||
</div>
|
hx-target="#user_new">
|
||||||
</div>
|
Add a New User
|
||||||
<div class="col-12 col-md-4 col-xl-2">
|
</a>
|
||||||
<div class="form-check form-switch">
|
<div class="container g-0">
|
||||||
<input type="checkbox" name="AutoHtmx" id="autoHtmx" class="form-check-input" value="true"
|
<div class="row mwl-table-heading">
|
||||||
{%- if model.auto_htmx %} checked="checked"{% endif %}>
|
<div class="{{ user_col }}">User<span class="d-md-none">; Full Name / E-mail; Last Log On</span></div>
|
||||||
<label for="autoHtmx" class="form-check-label">Auto-Load htmx</label>
|
<div class="{{ email_col }} d-none d-md-inline-block">Full Name / E-mail</div>
|
||||||
</div>
|
<div class="{{ cre8_col }}">Created</div>
|
||||||
<span class="form-text fst-italic">
|
<div class="{{ last_col }} d-none d-md-block">Last Log On</div>
|
||||||
<a href="https://htmx.org" target="_blank" rel="noopener">What is this?</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-md-4 col-xl-3 pb-3">
|
|
||||||
<div class="form-floating">
|
|
||||||
<select name="Uploads" id="uploads" class="form-control">
|
|
||||||
{%- for it in upload_values %}
|
|
||||||
<option value="{{ it[0] }}"{% if model.uploads == it[0] %} selected{% endif %}>{{ it[1] }}</option>
|
|
||||||
{%- endfor %}
|
|
||||||
</select>
|
|
||||||
<label for="uploads">Default Upload Destination</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row pb-3">
|
|
||||||
<div class="col text-center">
|
|
||||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
{{ user_list }}
|
||||||
|
</fieldset>
|
||||||
|
<fieldset id="rss-settings" class="container mb-3 pb-0">
|
||||||
|
<legend>RSS Settings</legend>
|
||||||
|
<form action="{{ "admin/settings/rss" | relative_link }}" method="post">
|
||||||
|
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row pb-3">
|
||||||
|
<div class="col col-xl-8 offset-xl-2">
|
||||||
|
<fieldset class="d-flex justify-content-evenly flex-row">
|
||||||
|
<legend>Feeds Enabled</legend>
|
||||||
|
<div class="form-check form-switch pb-2">
|
||||||
|
<input type="checkbox" name="IsFeedEnabled" id="feedEnabled" class="form-check-input" value="true"
|
||||||
|
{%- if rss_model.is_feed_enabled %} checked="checked"{% endif %}>
|
||||||
|
<label for="feedEnabled" class="form-check-label">All Posts</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch pb-2">
|
||||||
|
<input type="checkbox" name="IsCategoryEnabled" id="categoryEnabled" class="form-check-input"
|
||||||
|
value="true" {%- if rss_model.is_category_enabled %} checked="checked"{% endif %}>
|
||||||
|
<label for="categoryEnabled" class="form-check-label">Posts by Category</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch pb-2">
|
||||||
|
<input type="checkbox" name="IsTagEnabled" id="tagEnabled" class="form-check-input" value="true"
|
||||||
|
{%- if rss_model.tag_enabled %} checked="checked"{% endif %}>
|
||||||
|
<label for="tagEnabled" class="form-check-label">Posts by Tag</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-sm-6 col-md-3 col-xl-2 offset-xl-2 pb-3">
|
||||||
|
<div class="form-floating">
|
||||||
|
<input type="text" name="FeedName" id="feedName" class="form-control" placeholder="Feed File Name"
|
||||||
|
value="{{ rss_model.feed_name }}">
|
||||||
|
<label for="feedName">Feed File Name</label>
|
||||||
|
<span class="form-text">Default is <code>feed.xml</code></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-sm-6 col-md-4 col-xl-2 pb-3">
|
||||||
|
<div class="form-floating">
|
||||||
|
<input type="number" name="ItemsInFeed" id="itemsInFeed" class="form-control" min="0"
|
||||||
|
placeholder="Items in Feed" required value="{{ rss_model.items_in_feed }}">
|
||||||
|
<label for="itemsInFeed">Items in Feed</label>
|
||||||
|
<span class="form-text">Set to “0” to use “Posts per Page” setting ({{ web_log.posts_per_page }})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-5 col-xl-4 pb-3">
|
||||||
|
<div class="form-floating">
|
||||||
|
<input type="text" name="Copyright" id="copyright" class="form-control" placeholder="Copyright String"
|
||||||
|
value="{{ rss_model.copyright }}">
|
||||||
|
<label for="copyright">Copyright String</label>
|
||||||
|
<span class="form-text">
|
||||||
|
Can be a
|
||||||
|
<a href="https://creativecommons.org/share-your-work/" target="_blank" rel="noopener">
|
||||||
|
Creative Commons license string
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row pb-3">
|
||||||
|
<div class="col text-center">
|
||||||
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<fieldset class="container mb-3 pb-0">
|
||||||
|
<legend>Custom Feeds</legend>
|
||||||
|
<a class="btn btn-sm btn-secondary" href="{{ 'admin/settings/rss/new/edit' | relative_link }}">
|
||||||
|
Add a New Custom Feed
|
||||||
|
</a>
|
||||||
|
{%- assign feed_count = custom_feeds | size -%}
|
||||||
|
{%- if feed_count > 0 %}
|
||||||
|
<form method="post" class="container g-0" hx-target="body">
|
||||||
|
{%- assign source_col = "col-12 col-md-6" -%}
|
||||||
|
{%- assign path_col = "col-12 col-md-6" -%}
|
||||||
|
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||||
|
<div class="row mwl-table-heading">
|
||||||
|
<div class="{{ source_col }}">
|
||||||
|
<span class="d-md-none">Feed</span><span class="d-none d-md-inline">Source</span>
|
||||||
|
</div>
|
||||||
|
<div class="{{ path_col }} d-none d-md-inline-block">Relative Path</div>
|
||||||
|
</div>
|
||||||
|
{% for feed in custom_feeds %}
|
||||||
|
<div class="row mwl-table-detail">
|
||||||
|
<div class="{{ source_col }}">
|
||||||
|
{{ feed.source }}
|
||||||
|
{%- if feed.is_podcast %} <span class="badge bg-primary">PODCAST</span>{% endif %}<br>
|
||||||
|
<small>
|
||||||
|
{%- assign feed_url = "admin/settings/rss/" | append: feed.id -%}
|
||||||
|
<a href="{{ feed.path | relative_link }}" target="_blank">View Feed</a>
|
||||||
|
<span class="text-muted"> • </span>
|
||||||
|
<a href="{{ feed_url | append: "/edit" | relative_link }}">Edit</a>
|
||||||
|
<span class="text-muted"> • </span>
|
||||||
|
{%- assign feed_del_link = feed_url | append: "/delete" | relative_link -%}
|
||||||
|
<a href="{{ feed_del_link }}" hx-post="{{ feed_del_link }}" class="text-danger"
|
||||||
|
hx-confirm="Are you sure you want to delete the custom RSS feed based on {{ feed.source | strip_html | escape }}? This action cannot be undone.">
|
||||||
|
Delete
|
||||||
|
</a>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="{{ path_col }}">
|
||||||
|
<small class="d-md-none">Served at {{ feed.path }}</small>
|
||||||
|
<span class="d-none d-md-inline">{{ feed.path }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{%- endfor %}
|
||||||
|
</form>
|
||||||
|
{%- else %}
|
||||||
|
<p class="text-muted fst-italic text-center">No custom feeds defined</p>
|
||||||
|
{%- endif %}
|
||||||
|
</fieldset>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset id="tag-mappings" class="container mb-3 pb-0">
|
||||||
|
<legend>Tag Mappings</legend>
|
||||||
|
<a href="{{ "admin/settings/tag-mapping/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3"
|
||||||
|
hx-target="#tag_new">
|
||||||
|
Add a New Tag Mapping
|
||||||
|
</a>
|
||||||
|
{{ tag_mapping_list }}
|
||||||
|
</fieldset>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col text-center">
|
<div class="col text-center">
|
||||||
<button type="submit" class="btn btn-sm btn-primary">Save Changes</button>
|
<button type="submit" class="btn btn-sm btn-primary">Save Changes</button>
|
||||||
<a href="{{ "admin/settings/tag-mappings/bare" | relative_link }}" class="btn btn-sm btn-secondary ms-3">
|
<a href="{{ "admin/settings/tag-mappings" | relative_link }}" class="btn btn-sm btn-secondary ms-3">
|
||||||
Cancel
|
Cancel
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,33 +1,45 @@
|
|||||||
<form method="post" class="container" id="tagList" hx-target="this" hx-swap="outerHTML show:window:top">
|
<div id="tagList" class="container">
|
||||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
<div class="row">
|
||||||
<div class="row mwl-table-detail" id="tag_new"></div>
|
<div class="col">
|
||||||
{%- assign map_count = mappings | size -%}
|
{%- assign map_count = mappings | size -%}
|
||||||
{% if map_count > 0 -%}
|
{% if map_count > 0 -%}
|
||||||
{% for map in mappings -%}
|
<div class="container">
|
||||||
{%- assign map_id = mapping_ids | value: map.tag -%}
|
<div class="row mwl-table-heading">
|
||||||
<div class="row mwl-table-detail" id="tag_{{ map_id }}">
|
<div class="col">Tag</div>
|
||||||
<div class="col no-wrap">
|
<div class="col">URL Value</div>
|
||||||
{{ map.tag }}<br>
|
</div>
|
||||||
<small>
|
|
||||||
{%- assign map_url = "admin/settings/tag-mapping/" | append: map_id -%}
|
|
||||||
<a href="{{ map_url | append: "/edit" | relative_link }}" hx-target="#tag_{{ map_id }}"
|
|
||||||
hx-swap="innerHTML show:#tag_{{ map_id }}:top">
|
|
||||||
Edit
|
|
||||||
</a>
|
|
||||||
<span class="text-muted"> • </span>
|
|
||||||
{%- assign map_del_link = map_url | append: "/delete" | relative_link -%}
|
|
||||||
<a href="{{ map_del_link }}" hx-post="{{ map_del_link }}" class="text-danger"
|
|
||||||
hx-confirm="Are you sure you want to delete the mapping for “{{ map.tag }}”? This action cannot be undone.">
|
|
||||||
Delete
|
|
||||||
</a>
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col">{{ map.url_value }}</div>
|
<form method="post" class="container" hx-target="#tagList" hx-swap="outerHTML">
|
||||||
</div>
|
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||||
{%- endfor %}
|
<div class="row mwl-table-detail" id="tag_new"></div>
|
||||||
{%- else -%}
|
{% for map in mappings -%}
|
||||||
<div class="row">
|
{%- assign map_id = mapping_ids | value: map.tag -%}
|
||||||
<div class="col text-muted text-center fst-italic">This web log has no tag mappings</div>
|
<div class="row mwl-table-detail" id="tag_{{ map_id }}">
|
||||||
|
<div class="col no-wrap">
|
||||||
|
{{ map.tag }}<br>
|
||||||
|
<small>
|
||||||
|
{%- assign map_url = "admin/settings/tag-mapping/" | append: map_id -%}
|
||||||
|
<a href="{{ map_url | append: "/edit" | relative_link }}" hx-target="#tag_{{ map_id }}"
|
||||||
|
hx-swap="innerHTML show:#tag_{{ map_id }}:top">
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
<span class="text-muted"> • </span>
|
||||||
|
{%- assign map_del_link = map_url | append: "/delete" | relative_link -%}
|
||||||
|
<a href="{{ map_del_link }}" hx-post="{{ map_del_link }}" class="text-danger"
|
||||||
|
hx-confirm="Are you sure you want to delete the mapping for “{{ map.tag }}”? This action cannot be undone.">
|
||||||
|
Delete
|
||||||
|
</a>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="col">{{ map.url_value }}</div>
|
||||||
|
</div>
|
||||||
|
{%- endfor %}
|
||||||
|
</form>
|
||||||
|
{%- else -%}
|
||||||
|
<div id="tag_new">
|
||||||
|
<p class="text-muted text-center fst-italic">This web log has no tag mappings</p>
|
||||||
|
</div>
|
||||||
|
{%- endif %}
|
||||||
</div>
|
</div>
|
||||||
{%- endif %}
|
</div>
|
||||||
</form>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
<h2 class="my-3">{{ page_title }}</h2>
|
|
||||||
<article>
|
|
||||||
<a href="{{ "admin/settings/tag-mapping/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3"
|
|
||||||
hx-target="#tag_new">
|
|
||||||
Add a New Tag Mapping
|
|
||||||
</a>
|
|
||||||
<div class="container">
|
|
||||||
<div class="row mwl-table-heading">
|
|
||||||
<div class="col">Tag</div>
|
|
||||||
<div class="col">URL Value</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{ tag_mapping_list }}
|
|
||||||
</article>
|
|
||||||
33
src/admin-theme/theme-list-body.liquid
Normal file
33
src/admin-theme/theme-list-body.liquid
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<form method="post" id="themeList" class="container g-0" hx-target="this" hx-swap="outerHTML show:window:top">
|
||||||
|
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||||
|
{% include_template "_theme-list-columns" %}
|
||||||
|
{% for theme in themes -%}
|
||||||
|
<div class="row mwl-table-detail" id="theme_{{ theme.id }}">
|
||||||
|
<div class="{{ theme_col }} no-wrap">
|
||||||
|
{{ theme.name }}
|
||||||
|
{%- if theme.is_in_use %}
|
||||||
|
<span class="badge bg-primary ms-2">IN USE</span>
|
||||||
|
{%- endif %}
|
||||||
|
{%- unless theme.is_on_disk %}
|
||||||
|
<span class="badge bg-warning text-dark ms-2">NOT ON DISK</span>
|
||||||
|
{%- endunless %}<br>
|
||||||
|
<small>
|
||||||
|
<span class="text-muted">v{{ theme.version }}</span>
|
||||||
|
{% unless theme.is_in_use or theme.id == "default" %}
|
||||||
|
<span class="text-muted"> • </span>
|
||||||
|
{%- assign theme_del_link = "admin/theme/" | append: theme.id | append: "/delete" | relative_link -%}
|
||||||
|
<a href="{{ theme_del_link }}" hx-post="{{ theme_del_link }}" class="text-danger"
|
||||||
|
hx-confirm="Are you sure you want to delete the theme “{{ theme.name }}”? This action cannot be undone.">
|
||||||
|
Delete
|
||||||
|
</a>
|
||||||
|
{% endunless %}
|
||||||
|
<span class="d-md-none text-muted">
|
||||||
|
<br>Slug: {{ theme.id }} • {{ theme.template_count }} Templates
|
||||||
|
</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="{{ slug_col }}">{{ theme.id }}</div>
|
||||||
|
<div class="{{ tmpl_col }}">{{ theme.template_count }}</div>
|
||||||
|
</div>
|
||||||
|
{%- endfor %}
|
||||||
|
</form>
|
||||||
30
src/admin-theme/theme-upload.liquid
Normal file
30
src/admin-theme/theme-upload.liquid
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<div class="col">
|
||||||
|
<h5 class="mt-2">{{ page_title }}</h5>
|
||||||
|
<form action="{{ "admin/theme/new" | relative_link }}" method="post" class="container" enctype="multipart/form-data"
|
||||||
|
hx-boost="false">
|
||||||
|
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-sm-6 pb-3">
|
||||||
|
<div class="form-floating">
|
||||||
|
<input type="file" id="file" name="file" class="form-control" accept=".zip" placeholder="Theme File" required>
|
||||||
|
<label for="file">Theme File</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-sm-6 pb-3 d-flex justify-content-center align-items-center">
|
||||||
|
<div class="form-check form-switch pb-2">
|
||||||
|
<input type="checkbox" name="DoOverwrite" id="doOverwrite" class="form-check-input" value="true">
|
||||||
|
<label for="doOverwrite" class="form-check-label">Overwrite</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row pb-3">
|
||||||
|
<div class="col text-center">
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">Upload Theme</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary ms-3"
|
||||||
|
onclick="document.getElementById('theme_new').innerHTML = ''">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -7,20 +7,20 @@
|
|||||||
<form method="post" class="container" hx-target="body">
|
<form method="post" class="container" hx-target="body">
|
||||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col text-muted text-center"><em>Uploaded files served from</em><br>{{ upload_base }}</div>
|
<div class="col text-center"><em class="text-muted">Uploaded files served from</em><br>{{ upload_base }}</div>
|
||||||
</div>
|
|
||||||
<div class="row mwl-table-heading">
|
|
||||||
<div class="col-6">File Name</div>
|
|
||||||
<div class="col-3">Path</div>
|
|
||||||
<div class="col-3">File Date/Time</div>
|
|
||||||
</div>
|
</div>
|
||||||
{%- assign file_count = files | size -%}
|
{%- assign file_count = files | size -%}
|
||||||
{%- if file_count > 0 %}
|
{%- if file_count > 0 %}
|
||||||
|
<div class="row mwl-table-heading">
|
||||||
|
<div class="col-6">File Name</div>
|
||||||
|
<div class="col-3">Path</div>
|
||||||
|
<div class="col-3">File Date/Time</div>
|
||||||
|
</div>
|
||||||
{% for file in files %}
|
{% for file in files %}
|
||||||
<div class="row mwl-table-detail">
|
<div class="row mwl-table-detail">
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
{%- capture badge_class -%}
|
{%- capture badge_class -%}
|
||||||
{%- if file.source == "disk" %}secondary{% else %}primary{% endif -%}
|
{%- if file.source == "Disk" %}secondary{% else %}primary{% endif -%}
|
||||||
{%- endcapture -%}
|
{%- endcapture -%}
|
||||||
{%- assign path_and_name = file.path | append: file.name -%}
|
{%- assign path_and_name = file.path | append: file.name -%}
|
||||||
{%- assign blog_rel = upload_path | append: path_and_name -%}
|
{%- assign blog_rel = upload_path | append: path_and_name -%}
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
{% if is_web_log_admin %}
|
{% if is_web_log_admin %}
|
||||||
<span class="text-muted"> • </span>
|
<span class="text-muted"> • </span>
|
||||||
{%- capture delete_url -%}
|
{%- capture delete_url -%}
|
||||||
{%- if file.source == "disk" -%}
|
{%- if file.source == "Disk" -%}
|
||||||
admin/upload/delete/{{ path_and_name }}
|
admin/upload/delete/{{ path_and_name }}
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
admin/upload/{{ file.id }}/delete
|
admin/upload/{{ file.id }}/delete
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col text-muted fst-italic text-center">This web log has uploaded files</div>
|
<div class="col text-muted fst-italic text-center"><br>This web log has uploaded files</div>
|
||||||
</div>
|
</div>
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -13,11 +13,11 @@
|
|||||||
<div class="col-12 col-md-6 pb-3 d-flex align-self-center justify-content-around">
|
<div class="col-12 col-md-6 pb-3 d-flex align-self-center justify-content-around">
|
||||||
Destination<br>
|
Destination<br>
|
||||||
<div class="btn-group" role="group" aria-label="Upload destination button group">
|
<div class="btn-group" role="group" aria-label="Upload destination button group">
|
||||||
<input type="radio" name="Destination" id="destination_db" class="btn-check" value="database"
|
<input type="radio" name="Destination" id="destination_db" class="btn-check" value="Database"
|
||||||
{%- if destination == "database" %} checked="checked"{% endif %}>
|
{%- if destination == "Database" %} checked="checked"{% endif %}>
|
||||||
<label class="btn btn-outline-primary" for="destination_db">Database</label>
|
<label class="btn btn-outline-primary" for="destination_db">Database</label>
|
||||||
<input type="radio" name="Destination" id="destination_disk" class="btn-check" value="disk"
|
<input type="radio" name="Destination" id="destination_disk" class="btn-check" value="Disk"
|
||||||
{%- if destination == "disk" %} checked="checked"{% endif %}>
|
{%- if destination == "Disk" %} checked="checked"{% endif %}>
|
||||||
<label class="btn btn-outline-secondary" for="destination_disk">Disk</label>
|
<label class="btn btn-outline-secondary" for="destination_disk">Disk</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
<h2>Upload a Theme</h2>
|
|
||||||
<article>
|
|
||||||
<form action="{{ "admin/theme/update" | relative_link }}"
|
|
||||||
method="post" class="container" enctype="multipart/form-data" hx-boost="false">
|
|
||||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12 col-sm-6 offset-sm-3 pb-3">
|
|
||||||
<div class="form-floating">
|
|
||||||
<input type="file" id="file" name="file" class="form-control" accept=".zip" placeholder="Theme File" required>
|
|
||||||
<label for="file">Theme File</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-sm-6 pb-3">
|
|
||||||
<div class="form-check form-switch pb-2">
|
|
||||||
<input type="checkbox" name="clean" id="clean" class="form-check-input" value="true">
|
|
||||||
<label for="clean" class="form-check-label">Delete Existing Theme Files</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row pb-3">
|
|
||||||
<div class="col text-center">
|
|
||||||
<button type="submit" class="btn btn-primary">Upload Theme</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</article>
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<h5 class="my-3">{{ page_title }}</h5>
|
<h5 class="my-3">{{ page_title }}</h5>
|
||||||
<form hx-post="{{ "admin/user/save" | relative_link }}" method="post" class="container"
|
<form hx-post="{{ "admin/settings/user/save" | relative_link }}" method="post" class="container"
|
||||||
hx-target="#userList" hx-swap="outerHTML show:window:top">
|
hx-target="#userList" hx-swap="outerHTML show:window:top">
|
||||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||||
<input type="hidden" name="Id" value="{{ model.id }}">
|
<input type="hidden" name="Id" value="{{ model.id }}">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12 col-md-5 col-lg-3 col-xxl-2 offset-xxl-1 mb-3">
|
<div class="col-12 col-md-5 col-lg-3 col-xxl-2 offset-xxl-1 mb-3">
|
||||||
<div class="form-floating">
|
<div class="form-floating">
|
||||||
<select name="AccessLevel" id="accessLevel" class="form-control" required>
|
<select name="AccessLevel" id="accessLevel" class="form-control" required autofocus>
|
||||||
{%- for level in access_levels %}
|
{%- for level in access_levels %}
|
||||||
<option value="{{ level[0] }}"{% if model.access_level == level[0] %} selected{% endif %}>
|
<option value="{{ level[0] }}"{% if model.access_level == level[0] %} selected{% endif %}>
|
||||||
{{ level[1] }}
|
{{ level[1] }}
|
||||||
@@ -88,7 +88,14 @@
|
|||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col text-center">
|
<div class="col text-center">
|
||||||
<button type="submit" class="btn btn-sm btn-primary">Save Changes</button>
|
<button type="submit" class="btn btn-sm btn-primary">Save Changes</button>
|
||||||
<a href="{{ "admin/users/bare" | relative_link }}" class="btn btn-sm btn-secondary ms-3">Cancel</a>
|
{% if model.is_new %}
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary ms-3"
|
||||||
|
onclick="document.getElementById('user_new').innerHTML = ''">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ "admin/settings/users" | relative_link }}" class="btn btn-sm btn-secondary ms-3">Cancel</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,60 +1,61 @@
|
|||||||
<form method="post" id="userList" class="container" hx-target="this" hx-swap="outerHTML show:window:top">
|
<div id="userList">
|
||||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
<div class="container g-0">
|
||||||
<div class="row mwl-table-detail" id="user_new"></div>
|
<div class="row mwl-table-detail" id="user_new"></div>
|
||||||
{%- assign user_col = "col-12 col-md-4 col-xl-3" -%}
|
</div>
|
||||||
{%- assign email_col = "col-12 col-md-4 col-xl-4" -%}
|
<form method="post" id="userList" class="container g-0" hx-target="this" hx-swap="outerHTML show:window:top">
|
||||||
{%- assign cre8_col = "d-none d-xl-block col-xl-2" -%}
|
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||||
{%- assign last_col = "col-12 col-md-4 col-xl-3" -%}
|
{% include_template "_user-list-columns" %}
|
||||||
{%- assign badge = "ms-2 badge bg" -%}
|
{%- assign badge = "ms-2 badge bg" -%}
|
||||||
{% for user in users -%}
|
{% for user in users -%}
|
||||||
<div class="row mwl-table-detail" id="user_{{ user.id }}">
|
<div class="row mwl-table-detail" id="user_{{ user.id }}">
|
||||||
<div class="{{ user_col }} no-wrap">
|
<div class="{{ user_col }} no-wrap">
|
||||||
{{ user.preferred_name }}
|
{{ user.preferred_name }}
|
||||||
{%- if user.access_level == "Administrator" %}
|
{%- if user.access_level == "Administrator" %}
|
||||||
<span class="{{ badge }}-success">ADMINISTRATOR</span>
|
<span class="{{ badge }}-success">ADMINISTRATOR</span>
|
||||||
{%- elsif user.access_level == "WebLogAdmin" %}
|
{%- elsif user.access_level == "WebLogAdmin" %}
|
||||||
<span class="{{ badge }}-primary">WEB LOG ADMIN</span>
|
<span class="{{ badge }}-primary">WEB LOG ADMIN</span>
|
||||||
{%- elsif user.access_level == "Editor" %}
|
{%- elsif user.access_level == "Editor" %}
|
||||||
<span class="{{ badge }}-secondary">EDITOR</span>
|
<span class="{{ badge }}-secondary">EDITOR</span>
|
||||||
{%- elsif user.access_level == "Author" %}
|
{%- elsif user.access_level == "Author" %}
|
||||||
<span class="{{ badge }}-dark">AUTHOR</span>
|
<span class="{{ badge }}-dark">AUTHOR</span>
|
||||||
{%- endif %}<br>
|
{%- endif %}<br>
|
||||||
{%- unless is_administrator == false and user.access_level == "Administrator" %}
|
{%- unless is_administrator == false and user.access_level == "Administrator" %}
|
||||||
<small>
|
<small>
|
||||||
{%- assign user_url_base = "admin/user/" | append: user.id -%}
|
{%- assign user_url_base = "admin/settings/user/" | append: user.id -%}
|
||||||
<a href="{{ user_url_base | append: "/edit" | relative_link }}" hx-target="#user_{{ user.id }}"
|
<a href="{{ user_url_base | append: "/edit" | relative_link }}" hx-target="#user_{{ user.id }}"
|
||||||
hx-swap="innerHTML show:#user_{{ user.id }}:top">
|
hx-swap="innerHTML show:#user_{{ user.id }}:top">
|
||||||
Edit
|
Edit
|
||||||
</a>
|
|
||||||
{% unless user_id == user.id %}
|
|
||||||
<span class="text-muted"> • </span>
|
|
||||||
{%- assign user_del_link = user_url_base | append: "/delete" | relative_link -%}
|
|
||||||
<a href="{{ user_del_link }}" hx-post="{{ user_del_link }}" class="text-danger"
|
|
||||||
hx-confirm="Are you sure you want to delete the user “{{ user.preferred_name }}”? This action cannot be undone. (This action will not succeed if the user has authored any posts or pages.)">
|
|
||||||
Delete
|
|
||||||
</a>
|
</a>
|
||||||
{% endunless %}
|
{% unless user_id == user.id %}
|
||||||
|
<span class="text-muted"> • </span>
|
||||||
|
{%- assign user_del_link = user_url_base | append: "/delete" | relative_link -%}
|
||||||
|
<a href="{{ user_del_link }}" hx-post="{{ user_del_link }}" class="text-danger"
|
||||||
|
hx-confirm="Are you sure you want to delete the user “{{ user.preferred_name }}”? This action cannot be undone. (This action will not succeed if the user has authored any posts or pages.)">
|
||||||
|
Delete
|
||||||
|
</a>
|
||||||
|
{% endunless %}
|
||||||
|
</small>
|
||||||
|
{%- endunless %}
|
||||||
|
</div>
|
||||||
|
<div class="{{ email_col }}">
|
||||||
|
{{ user.first_name }} {{ user.last_name }}<br>
|
||||||
|
<small class="text-muted">
|
||||||
|
{{ user.email }}
|
||||||
|
{%- unless user.url == "" %}<br>{{ user.url }}{% endunless %}
|
||||||
</small>
|
</small>
|
||||||
{%- endunless %}
|
</div>
|
||||||
|
<div class="{{ cre8_col }}">
|
||||||
|
{{ user.created_on | date: "MMMM d, yyyy" }}
|
||||||
|
</div>
|
||||||
|
<div class="{{ last_col }}">
|
||||||
|
{% if user.last_seen_on %}
|
||||||
|
{{ user.last_seen_on | date: "MMMM d, yyyy" }} at
|
||||||
|
{{ user.last_seen_on | date: "h:mmtt" | downcase }}
|
||||||
|
{% else %}
|
||||||
|
--
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="{{ email_col }}">
|
{%- endfor %}
|
||||||
{{ user.first_name }} {{ user.last_name }}<br>
|
</form>
|
||||||
<small class="text-muted">
|
</div>
|
||||||
{{ user.email }}
|
|
||||||
{%- unless user.url == "" %}<br>{{ user.url }}{% endunless %}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div class="{{ cre8_col }}">
|
|
||||||
{{ user.created_on | date: "MMMM d, yyyy" }}
|
|
||||||
</div>
|
|
||||||
<div class="{{ last_col }}">
|
|
||||||
{% if user.last_seen_on %}
|
|
||||||
{{ user.last_seen_on | date: "MMMM d, yyyy" }} at
|
|
||||||
{{ user.last_seen_on | date: "h:mmtt" | downcase }}
|
|
||||||
{% else %}
|
|
||||||
--
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{%- endfor %}
|
|
||||||
</form>
|
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
<h2 class="my-3">{{ page_title }}</h2>
|
|
||||||
<article>
|
|
||||||
<a href="{{ "admin/user/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3"
|
|
||||||
hx-target="#user_new">
|
|
||||||
Add a New User
|
|
||||||
</a>
|
|
||||||
<div class="container">
|
|
||||||
{%- assign user_col = "col-12 col-md-4 col-xl-3" -%}
|
|
||||||
{%- assign email_col = "col-12 col-md-4 col-xl-4" -%}
|
|
||||||
{%- assign cre8_col = "d-none d-xl-block col-xl-2" -%}
|
|
||||||
{%- assign last_col = "col-12 col-md-4 col-xl-3" -%}
|
|
||||||
<div class="row mwl-table-heading">
|
|
||||||
<div class="{{ user_col }}">User<span class="d-md-none">; Details; Last Log On</span></div>
|
|
||||||
<div class="{{ email_col }} d-none d-md-inline-block">Details</div>
|
|
||||||
<div class="{{ cre8_col }}">Created</div>
|
|
||||||
<div class="{{ last_col }} d-none d-md-block">Last Log On</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{ user_list }}
|
|
||||||
</article>
|
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
myWebLog Admin
|
myWebLog Admin
|
||||||
2.0.0-beta05
|
2.0.0-rc2
|
||||||
@@ -29,7 +29,6 @@ header nav {
|
|||||||
footer {
|
footer {
|
||||||
background-color: #808080;
|
background-color: #808080;
|
||||||
border-top: solid 1px black;
|
border-top: solid 1px black;
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
.messages {
|
.messages {
|
||||||
max-width: 60rem;
|
max-width: 60rem;
|
||||||
@@ -92,3 +91,27 @@ a.text-danger:link:hover, a.text-danger:visited:hover {
|
|||||||
border-radius: .5rem;
|
border-radius: .5rem;
|
||||||
padding: .5rem;
|
padding: .5rem;
|
||||||
}
|
}
|
||||||
|
.load-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 55px;
|
||||||
|
left: 0;
|
||||||
|
z-index: 100;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, .1);
|
||||||
|
transition: ease-in-out .5s;
|
||||||
|
}
|
||||||
|
.load-overlay h1 {
|
||||||
|
background-color: rgba(204, 204, 0, .95);
|
||||||
|
height: fit-content;
|
||||||
|
border: solid 6px darkgreen;
|
||||||
|
border-radius: 2rem;
|
||||||
|
}
|
||||||
|
.load-overlay.htmx-request {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row;
|
||||||
|
}
|
||||||
|
#toastHost {
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|||||||
@@ -293,33 +293,46 @@ this.Admin = {
|
|||||||
const parts = msg.split("|||")
|
const parts = msg.split("|||")
|
||||||
if (parts.length < 2) return
|
if (parts.length < 2) return
|
||||||
|
|
||||||
const msgDiv = document.createElement("div")
|
// Create the toast header
|
||||||
msgDiv.className = `alert alert-${parts[0]} alert-dismissible fade show`
|
const toastType = document.createElement("strong")
|
||||||
msgDiv.setAttribute("role", "alert")
|
toastType.className = "me-auto text-uppercase"
|
||||||
msgDiv.innerHTML = parts[1]
|
toastType.innerText = parts[0] === "danger" ? "error" : parts[0]
|
||||||
|
|
||||||
const closeBtn = document.createElement("button")
|
const closeBtn = document.createElement("button")
|
||||||
closeBtn.type = "button"
|
closeBtn.type = "button"
|
||||||
closeBtn.className = "btn-close"
|
closeBtn.className = "btn-close"
|
||||||
closeBtn.setAttribute("data-bs-dismiss", "alert")
|
closeBtn.setAttribute("data-bs-dismiss", "toast")
|
||||||
closeBtn.setAttribute("aria-label", "Close")
|
closeBtn.setAttribute("aria-label", "Close")
|
||||||
msgDiv.appendChild(closeBtn)
|
|
||||||
|
|
||||||
if (parts.length === 3) {
|
|
||||||
msgDiv.innerHTML += `<hr>${parts[2]}`
|
|
||||||
}
|
|
||||||
document.getElementById("msgContainer").appendChild(msgDiv)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
const toastHead = document.createElement("div")
|
||||||
* Set all "success" alerts to close after 4 seconds
|
toastHead.className = `toast-header bg-${parts[0]}${parts[0] === "warning" ? "" : " text-white"}`
|
||||||
*/
|
toastHead.appendChild(toastType)
|
||||||
dismissSuccesses() {
|
toastHead.appendChild(closeBtn)
|
||||||
[...document.querySelectorAll(".alert-success")].forEach(alert => {
|
|
||||||
setTimeout(() => {
|
// Create the toast body
|
||||||
(bootstrap.Alert.getInstance(alert) ?? new bootstrap.Alert(alert)).close()
|
const toastBody = document.createElement("div")
|
||||||
}, 4000)
|
toastBody.className = `toast-body bg-${parts[0]} bg-opacity-25`
|
||||||
|
toastBody.innerHTML = parts[1]
|
||||||
|
if (parts.length === 3) {
|
||||||
|
toastBody.innerHTML += `<hr>${parts[2]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assemble the toast
|
||||||
|
const toast = document.createElement("div")
|
||||||
|
toast.className = "toast"
|
||||||
|
toast.setAttribute("role", "alert")
|
||||||
|
toast.setAttribute("aria-live", "assertive")
|
||||||
|
toast.setAttribute("aria-atomic", "true")
|
||||||
|
toast.appendChild(toastHead)
|
||||||
|
toast.appendChild(toastBody)
|
||||||
|
|
||||||
|
document.getElementById("toasts").appendChild(toast)
|
||||||
|
|
||||||
|
let options = { delay: 4000 }
|
||||||
|
if (parts[0] !== "success") options.autohide = false
|
||||||
|
|
||||||
|
const theToast = new bootstrap.Toast(toast, options)
|
||||||
|
theToast.show()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -329,8 +342,19 @@ htmx.on("htmx:afterOnLoad", function (evt) {
|
|||||||
// Show messages if there were any in the response
|
// Show messages if there were any in the response
|
||||||
if (hdrs.indexOf("x-message") >= 0) {
|
if (hdrs.indexOf("x-message") >= 0) {
|
||||||
Admin.showMessage(evt.detail.xhr.getResponseHeader("x-message"))
|
Admin.showMessage(evt.detail.xhr.getResponseHeader("x-message"))
|
||||||
Admin.dismissSuccesses()
|
|
||||||
}
|
}
|
||||||
|
// Initialize any toasts that were pre-rendered from the server
|
||||||
|
[...document.querySelectorAll(".toast")].forEach(el => {
|
||||||
|
if (el.getAttribute("data-mwl-shown") === "true" && el.className.indexOf("hide") >= 0) {
|
||||||
|
document.removeChild(el)
|
||||||
|
} else {
|
||||||
|
const toast = new bootstrap.Toast(el,
|
||||||
|
el.getAttribute("data-bs-autohide") === "false"
|
||||||
|
? { autohide: false } : { delay: 6000, autohide: true })
|
||||||
|
toast.show()
|
||||||
|
el.setAttribute("data-mwl-shown", "true")
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
htmx.on("htmx:responseError", function (evt) {
|
htmx.on("htmx:responseError", function (evt) {
|
||||||
|
|||||||
@@ -1,56 +1,60 @@
|
|||||||
{%- if is_category or is_tag %}
|
{%- if is_category or is_tag %}
|
||||||
<h1 class="index-title">{{ page_title }}</h1>
|
<h1 class="index-title">{{ page_title }}</h1>
|
||||||
{%- if is_category %}
|
{%- if subtitle %}<h4 class="text-muted">{{ subtitle }}</h4>{% endif -%}
|
||||||
{%- assign cat = categories | where: "slug", slug | first -%}
|
{% endif %}
|
||||||
{%- if cat.description %}<h4 class="text-muted">{{ cat.description.value }}</h4>{% endif -%}
|
{%- assign post_count = model.posts | size -%}
|
||||||
{%- endif %}
|
{%- if post_count > 0 %}
|
||||||
{%- endif %}
|
<section class="container mt-3" aria-label="The posts for the page">
|
||||||
<section class="container mt-3" aria-label="The posts for the page">
|
{%- for post in model.posts %}
|
||||||
{% for post in model.posts %}
|
<article>
|
||||||
<article>
|
<h1>
|
||||||
<h1>
|
<a href="{{ post | relative_link }}" title="Permanent link to "{{ post.title | escape }}"">
|
||||||
<a href="{{ post | relative_link }}" title="Permanent link to "{{ post.title | escape }}"">
|
{{ post.title }}
|
||||||
{{ post.title }}
|
</a>
|
||||||
</a>
|
</h1>
|
||||||
</h1>
|
<p>
|
||||||
<p>
|
Published on {{ post.published_on | date: "MMMM d, yyyy" }}
|
||||||
Published on {{ post.published_on | date: "MMMM d, yyyy" }}
|
at {{ post.published_on | date: "h:mmtt" | downcase }}
|
||||||
at {{ post.published_on | date: "h:mmtt" | downcase }}
|
by {{ model.authors | value: post.author_id }}
|
||||||
by {{ model.authors | value: post.author_id }}
|
</p>
|
||||||
</p>
|
{{ post.text }}
|
||||||
{{ post.text }}
|
{%- assign category_count = post.category_ids | size -%}
|
||||||
{%- assign category_count = post.category_ids | size -%}
|
{%- assign tag_count = post.tags | size -%}
|
||||||
{%- assign tag_count = post.tags | size -%}
|
{% if category_count > 0 or tag_count > 0 %}
|
||||||
{% if category_count > 0 or tag_count > 0 %}
|
<footer>
|
||||||
<footer>
|
<p>
|
||||||
<p>
|
{%- if category_count > 0 -%}
|
||||||
{%- if category_count > 0 -%}
|
Categorized under:
|
||||||
Categorized under:
|
{% for cat in post.category_ids -%}
|
||||||
{% for cat in post.category_ids -%}
|
{%- assign this_cat = categories | where: "Id", cat | first -%}
|
||||||
{%- assign this_cat = categories | where: "id", cat | first -%}
|
{{ this_cat.name }}{% unless forloop.last %}, {% endunless %}
|
||||||
{{ this_cat.name }}{% unless forloop.last %}, {% endunless %}
|
{%- assign cat_names = this_cat.name | concat: cat_names -%}
|
||||||
{%- assign cat_names = this_cat.name | concat: cat_names -%}
|
{%- endfor -%}
|
||||||
{%- endfor -%}
|
{%- assign cat_names = "" -%}
|
||||||
{%- assign cat_names = "" -%}
|
<br>
|
||||||
<br>
|
{% endif -%}
|
||||||
{% endif -%}
|
{%- if tag_count > 0 %}
|
||||||
{%- if tag_count > 0 %}
|
Tagged: {{ post.tags | join: ", " }}
|
||||||
Tagged: {{ post.tags | join: ", " }}
|
{% endif -%}
|
||||||
{% endif -%}
|
</p>
|
||||||
</p>
|
</footer>
|
||||||
</footer>
|
{% endif %}
|
||||||
|
<hr>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
<nav aria-label="pagination">
|
||||||
|
<ul class="pagination justify-content-evenly mt-2">
|
||||||
|
{% if model.newer_link -%}
|
||||||
|
<li class="page-item"><a class="page-link" href="{{ model.newer_link.value }}">« Newer Posts</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<hr>
|
{% if model.older_link -%}
|
||||||
</article>
|
<li class="page-item"><a class="page-link" href="{{ model.older_link.value }}">Older Posts »</a></li>
|
||||||
{% endfor %}
|
{%- endif -%}
|
||||||
</section>
|
</ul>
|
||||||
<nav aria-label="pagination">
|
</nav>
|
||||||
<ul class="pagination justify-content-evenly mt-2">
|
{%- else %}
|
||||||
{% if model.newer_link -%}
|
<article>
|
||||||
<li class="page-item"><a class="page-link" href="{{ model.newer_link.value }}">« Newer Posts</a></li>
|
<p class="text-center mt-3">No posts found</p>
|
||||||
{% endif %}
|
</article>
|
||||||
{% if model.older_link -%}
|
{%- endif %}
|
||||||
<li class="page-item"><a class="page-link" href="{{ model.older_link.value }}">Older Posts »</a></li>
|
|
||||||
{%- endif -%}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width">
|
<meta name="viewport" content="width=device-width">
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
|
||||||
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
|
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
|
||||||
<title>{{ page_title | strip_html }}{% if page_title %} « {% endif %}{{ web_log.name | strip_html }}</title>
|
<title>{{ page_title | strip_html }}{% if page_title %} « {% endif %}{{ web_log.name | strip_html }}</title>
|
||||||
{% page_head -%}
|
{% page_head -%}
|
||||||
</head>
|
</head>
|
||||||
@@ -55,8 +55,8 @@
|
|||||||
<img src="{{ "themes/admin/logo-dark.png" | relative_link }}" alt="myWebLog" width="120" height="34">
|
<img src="{{ "themes/admin/logo-dark.png" | relative_link }}" alt="myWebLog" width="120" height="34">
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"
|
||||||
integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
|
integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<h4 class="item-meta text-muted">
|
<h4 class="item-meta text-muted">
|
||||||
Categorized under
|
Categorized under
|
||||||
{% for cat_id in post.category_ids -%}
|
{% for cat_id in post.category_ids -%}
|
||||||
{% assign cat = categories | where: "id", cat_id | first %}
|
{% assign cat = categories | where: "Id", cat_id | first %}
|
||||||
<span class="text-nowrap">
|
<span class="text-nowrap">
|
||||||
<a href="{{ cat | category_link }}" title="Categorized under “{{ cat.name | escape }}”">
|
<a href="{{ cat | category_link }}" title="Categorized under “{{ cat.name | escape }}”">
|
||||||
{{ cat.name }}
|
{{ cat.name }}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
myWebLog Default Theme
|
myWebLog Default Theme
|
||||||
2.0.0-alpha36
|
2.0.0-rc2
|
||||||
Reference in New Issue
Block a user