Convert Data Storage to PostgreSQL Documents #74
No files matched your search
@@ -254,3 +254,5 @@ paket-files/
|
|||||||
|
|
||||||
# Ionide VSCode extension
|
# Ionide VSCode extension
|
||||||
.ionide
|
.ionide
|
||||||
|
|
||||||
|
src/environment.txt
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/sdk:7.0-alpine AS build
|
||||||
|
WORKDIR /mpj
|
||||||
|
COPY ./MyPrayerJournal/MyPrayerJournal.fsproj ./
|
||||||
|
RUN dotnet restore
|
||||||
|
|
||||||
|
COPY ./MyPrayerJournal ./
|
||||||
|
RUN dotnet publish -c Release -r linux-x64
|
||||||
|
RUN rm bin/Release/net7.0/linux-x64/publish/appsettings.*.json
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:7.0-alpine as final
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache icu-libs
|
||||||
|
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||||
|
COPY --from=build /mpj/bin/Release/net7.0/linux-x64/publish/ ./
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
CMD [ "dotnet", "/app/MyPrayerJournal.dll" ]
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
open MyPrayerJournal.Domain
|
|
||||||
open NodaTime
|
|
||||||
|
|
||||||
/// The old definition of the history entry
|
|
||||||
[<CLIMutable; NoComparison; NoEquality>]
|
|
||||||
type OldHistory =
|
|
||||||
{ /// The time when this history entry was made
|
|
||||||
asOf : int64
|
|
||||||
/// The status for this history entry
|
|
||||||
status : RequestAction
|
|
||||||
/// The text of the update, if applicable
|
|
||||||
text : string option
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The old definition of of the note entry
|
|
||||||
[<CLIMutable; NoComparison; NoEquality>]
|
|
||||||
type OldNote =
|
|
||||||
{ /// The time when this note was made
|
|
||||||
asOf : int64
|
|
||||||
|
|
||||||
/// The text of the notes
|
|
||||||
notes : string
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Request is the identifying record for a prayer request
|
|
||||||
[<CLIMutable; NoComparison; NoEquality>]
|
|
||||||
type OldRequest =
|
|
||||||
{ /// The ID of the request
|
|
||||||
id : RequestId
|
|
||||||
|
|
||||||
/// The time this request was initially entered
|
|
||||||
enteredOn : int64
|
|
||||||
|
|
||||||
/// The ID of the user to whom this request belongs ("sub" from the JWT)
|
|
||||||
userId : UserId
|
|
||||||
|
|
||||||
/// The time at which this request should reappear in the user's journal by manual user choice
|
|
||||||
snoozedUntil : int64
|
|
||||||
|
|
||||||
/// The time at which this request should reappear in the user's journal by recurrence
|
|
||||||
showAfter : int64
|
|
||||||
|
|
||||||
/// The type of recurrence for this request
|
|
||||||
recurType : string
|
|
||||||
|
|
||||||
/// How many of the recurrence intervals should occur between appearances in the journal
|
|
||||||
recurCount : int16
|
|
||||||
|
|
||||||
/// The history entries for this request
|
|
||||||
history : OldHistory[]
|
|
||||||
|
|
||||||
/// The notes for this request
|
|
||||||
notes : OldNote[]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
open LiteDB
|
|
||||||
open MyPrayerJournal.Data
|
|
||||||
|
|
||||||
let db = new LiteDatabase ("Filename=./mpj.db")
|
|
||||||
Startup.ensureDb db
|
|
||||||
|
|
||||||
/// Map the old recurrence to the new style
|
|
||||||
let mapRecurrence old =
|
|
||||||
match old.recurType with
|
|
||||||
| "Days" -> Days old.recurCount
|
|
||||||
| "Hours" -> Hours old.recurCount
|
|
||||||
| "Weeks" -> Weeks old.recurCount
|
|
||||||
| _ -> Immediate
|
|
||||||
|
|
||||||
/// Convert an old history entry to the new form
|
|
||||||
let convertHistory (old : OldHistory) =
|
|
||||||
{ AsOf = Instant.FromUnixTimeMilliseconds old.asOf
|
|
||||||
Status = old.status
|
|
||||||
Text = old.text
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert an old note to the new form
|
|
||||||
let convertNote (old : OldNote) =
|
|
||||||
{ AsOf = Instant.FromUnixTimeMilliseconds old.asOf
|
|
||||||
Notes = old.notes
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert items that may be Instant.MinValue or Instant(0) to None
|
|
||||||
let noneIfOld ms =
|
|
||||||
match Instant.FromUnixTimeMilliseconds ms with
|
|
||||||
| instant when instant > Instant.FromUnixTimeMilliseconds 0 -> Some instant
|
|
||||||
| _ -> None
|
|
||||||
|
|
||||||
/// Map the old request to the new request
|
|
||||||
let convert old =
|
|
||||||
{ Id = old.id
|
|
||||||
EnteredOn = Instant.FromUnixTimeMilliseconds old.enteredOn
|
|
||||||
UserId = old.userId
|
|
||||||
SnoozedUntil = noneIfOld old.snoozedUntil
|
|
||||||
ShowAfter = noneIfOld old.showAfter
|
|
||||||
Recurrence = mapRecurrence old
|
|
||||||
History = old.history |> Array.map convertHistory |> List.ofArray
|
|
||||||
Notes = old.notes |> Array.map convertNote |> List.ofArray
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove the old request, add the converted one (removes recurType / recurCount fields)
|
|
||||||
let replace (req : Request) =
|
|
||||||
db.Requests.Delete (Mapping.RequestId.toBson req.Id) |> ignore
|
|
||||||
db.Requests.Insert req |> ignore
|
|
||||||
db.Checkpoint ()
|
|
||||||
|
|
||||||
db.GetCollection<OldRequest>("request").FindAll ()
|
|
||||||
|> Seq.map convert
|
|
||||||
|> List.ofSeq
|
|
||||||
|> List.iter replace
|
|
||||||
|
|
||||||
// For more information see https://aka.ms/fsharp-console-apps
|
|
||||||
printfn "Done"
|
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
module MyPrayerJournal.LiteData
|
||||||
|
|
||||||
|
open LiteDB
|
||||||
|
open MyPrayerJournal
|
||||||
|
open NodaTime
|
||||||
|
|
||||||
|
/// Request is the identifying record for a prayer request
|
||||||
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
|
type OldRequest =
|
||||||
|
{ /// The ID of the request
|
||||||
|
Id : RequestId
|
||||||
|
|
||||||
|
/// The time this request was initially entered
|
||||||
|
EnteredOn : Instant
|
||||||
|
|
||||||
|
/// The ID of the user to whom this request belongs ("sub" from the JWT)
|
||||||
|
UserId : UserId
|
||||||
|
|
||||||
|
/// The time at which this request should reappear in the user's journal by manual user choice
|
||||||
|
SnoozedUntil : Instant option
|
||||||
|
|
||||||
|
/// The time at which this request should reappear in the user's journal by recurrence
|
||||||
|
ShowAfter : Instant option
|
||||||
|
|
||||||
|
/// The recurrence for this request
|
||||||
|
Recurrence : Recurrence
|
||||||
|
|
||||||
|
/// The history entries for this request
|
||||||
|
History : History[]
|
||||||
|
|
||||||
|
/// The notes for this request
|
||||||
|
Notes : Note[]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// LiteDB extensions
|
||||||
|
[<AutoOpen>]
|
||||||
|
module Extensions =
|
||||||
|
|
||||||
|
/// Extensions on the LiteDatabase class
|
||||||
|
type LiteDatabase with
|
||||||
|
|
||||||
|
/// The Request collection
|
||||||
|
member this.Requests = this.GetCollection<OldRequest> "request"
|
||||||
|
|
||||||
|
|
||||||
|
/// Map domain to LiteDB
|
||||||
|
// It does mapping, but since we're so DU-heavy, this gives us control over the JSON representation
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Mapping =
|
||||||
|
|
||||||
|
open NodaTime.Text
|
||||||
|
|
||||||
|
/// A NodaTime instant pattern to use for parsing instants from the database
|
||||||
|
let instantPattern = InstantPattern.CreateWithInvariantCulture "g"
|
||||||
|
|
||||||
|
/// Mapping for NodaTime's Instant type
|
||||||
|
module Instant =
|
||||||
|
let fromBson (value : BsonValue) = (instantPattern.Parse value.AsString).Value
|
||||||
|
let toBson (value : Instant) : BsonValue = value.ToString ("g", null)
|
||||||
|
|
||||||
|
/// Mapping for option types
|
||||||
|
module Option =
|
||||||
|
let instantFromBson (value : BsonValue) = if value.IsNull then None else Some (Instant.fromBson value)
|
||||||
|
let instantToBson (value : Instant option) = match value with Some it -> Instant.toBson it | None -> null
|
||||||
|
|
||||||
|
let stringFromBson (value : BsonValue) = match value.AsString with "" -> None | x -> Some x
|
||||||
|
let stringToBson (value : string option) : BsonValue = match value with Some txt -> txt | None -> ""
|
||||||
|
|
||||||
|
/// Mapping for Recurrence
|
||||||
|
module Recurrence =
|
||||||
|
let fromBson (value : BsonValue) = Recurrence.ofString value
|
||||||
|
let toBson (value : Recurrence) : BsonValue = Recurrence.toString value
|
||||||
|
|
||||||
|
/// Mapping for RequestAction
|
||||||
|
module RequestAction =
|
||||||
|
let fromBson (value : BsonValue) = RequestAction.ofString value.AsString
|
||||||
|
let toBson (value : RequestAction) : BsonValue = RequestAction.toString value
|
||||||
|
|
||||||
|
/// Mapping for RequestId
|
||||||
|
module RequestId =
|
||||||
|
let fromBson (value : BsonValue) = RequestId.ofString value.AsString
|
||||||
|
let toBson (value : RequestId) : BsonValue = RequestId.toString value
|
||||||
|
|
||||||
|
/// Mapping for UserId
|
||||||
|
module UserId =
|
||||||
|
let fromBson (value : BsonValue) = UserId value.AsString
|
||||||
|
let toBson (value : UserId) : BsonValue = UserId.toString value
|
||||||
|
|
||||||
|
/// Set up the mapping
|
||||||
|
let register () =
|
||||||
|
BsonMapper.Global.RegisterType<Instant>(Instant.toBson, Instant.fromBson)
|
||||||
|
BsonMapper.Global.RegisterType<Instant option>(Option.instantToBson, Option.instantFromBson)
|
||||||
|
BsonMapper.Global.RegisterType<Recurrence>(Recurrence.toBson, Recurrence.fromBson)
|
||||||
|
BsonMapper.Global.RegisterType<RequestAction>(RequestAction.toBson, RequestAction.fromBson)
|
||||||
|
BsonMapper.Global.RegisterType<RequestId>(RequestId.toBson, RequestId.fromBson)
|
||||||
|
BsonMapper.Global.RegisterType<string option>(Option.stringToBson, Option.stringFromBson)
|
||||||
|
BsonMapper.Global.RegisterType<UserId>(UserId.toBson, UserId.fromBson)
|
||||||
|
|
||||||
|
/// Code to be run at startup
|
||||||
|
module Startup =
|
||||||
|
|
||||||
|
/// Ensure the database is set up
|
||||||
|
let ensureDb (db : LiteDatabase) =
|
||||||
|
db.Requests.EnsureIndex (fun it -> it.UserId) |> ignore
|
||||||
|
Mapping.register ()
|
||||||
+8
-1
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
|
<NoWarn>3391</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<Compile Include="LiteData.fs" />
|
||||||
<Compile Include="Program.fs" />
|
<Compile Include="Program.fs" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
@@ -13,4 +15,9 @@
|
|||||||
<ProjectReference Include="..\MyPrayerJournal\MyPrayerJournal.fsproj" />
|
<ProjectReference Include="..\MyPrayerJournal\MyPrayerJournal.fsproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="LiteDB" Version="5.0.17" />
|
||||||
|
<PackageReference Update="FSharp.Core" Version="7.0.400" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
open LiteDB
|
||||||
|
open MyPrayerJournal.Data
|
||||||
|
open MyPrayerJournal.Domain
|
||||||
|
open MyPrayerJournal.LiteData
|
||||||
|
open Microsoft.Extensions.Configuration
|
||||||
|
|
||||||
|
|
||||||
|
let lite = new LiteDatabase "Filename=./mpj.db"
|
||||||
|
Startup.ensureDb lite
|
||||||
|
|
||||||
|
let cfg = (ConfigurationBuilder().AddJsonFile "appsettings.json").Build ()
|
||||||
|
Connection.setUp cfg |> Async.AwaitTask |> Async.RunSynchronously
|
||||||
|
|
||||||
|
let reqs = lite.Requests.FindAll ()
|
||||||
|
|
||||||
|
reqs
|
||||||
|
|> Seq.map (fun old ->
|
||||||
|
{ Request.empty with
|
||||||
|
Id = old.Id
|
||||||
|
EnteredOn = old.EnteredOn
|
||||||
|
UserId = old.UserId
|
||||||
|
SnoozedUntil = old.SnoozedUntil
|
||||||
|
ShowAfter = old.ShowAfter
|
||||||
|
Recurrence = old.Recurrence
|
||||||
|
History = old.History |> Array.sortByDescending (fun it -> it.AsOf) |> List.ofArray
|
||||||
|
Notes = old.Notes |> Array.sortByDescending (fun it -> it.AsOf) |> List.ofArray
|
||||||
|
})
|
||||||
|
|> Seq.map Request.add
|
||||||
|
|> List.ofSeq
|
||||||
|
|> List.iter (Async.AwaitTask >> Async.RunSynchronously)
|
||||||
|
|
||||||
|
System.Console.WriteLine $"Migration complete - {Seq.length reqs} requests migrated"
|
||||||
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio Version 16
|
# Visual Studio Version 16
|
||||||
VisualStudioVersion = 16.0.30114.105
|
VisualStudioVersion = 16.0.30114.105
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "MyPrayerJournal", "MyPrayerJournal\MyPrayerJournal.fsproj", "{6BD5A3C8-F859-42A0-ACD7-A5819385E828}"
|
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "MyPrayerJournal", "MyPrayerJournal\MyPrayerJournal.fsproj", "{6BD5A3C8-F859-42A0-ACD7-A5819385E828}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "MyPrayerJournal.ConvertRecurrence", "MyPrayerJournal.ConvertRecurrence\MyPrayerJournal.ConvertRecurrence.fsproj", "{72B57736-8721-4636-A309-49FA4222416E}"
|
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "MyPrayerJournal.ToPostgres", "MyPrayerJournal.ToPostgres\MyPrayerJournal.ToPostgres.fsproj", "{3114B8F4-E388-4804-94D3-A2F4D42797C6}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
@@ -24,5 +24,9 @@ Global
|
|||||||
{72B57736-8721-4636-A309-49FA4222416E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{72B57736-8721-4636-A309-49FA4222416E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{72B57736-8721-4636-A309-49FA4222416E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{72B57736-8721-4636-A309-49FA4222416E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{72B57736-8721-4636-A309-49FA4222416E}.Release|Any CPU.Build.0 = Release|Any CPU
|
{72B57736-8721-4636-A309-49FA4222416E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{3114B8F4-E388-4804-94D3-A2F4D42797C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{3114B8F4-E388-4804-94D3-A2F4D42797C6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{3114B8F4-E388-4804-94D3-A2F4D42797C6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{3114B8F4-E388-4804-94D3-A2F4D42797C6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
@@ -1,5 +1,2 @@
|
|||||||
## LiteDB database file
|
|
||||||
*.db
|
|
||||||
|
|
||||||
## Development settings
|
## Development settings
|
||||||
appsettings.Development.json
|
appsettings.Development.json
|
||||||
+179
-173
@@ -1,199 +1,205 @@
|
|||||||
module MyPrayerJournal.Data
|
module MyPrayerJournal.Data
|
||||||
|
|
||||||
open LiteDB
|
/// Table(!) used by myPrayerJournal
|
||||||
open MyPrayerJournal
|
module Table =
|
||||||
open System.Threading.Tasks
|
|
||||||
|
|
||||||
/// LiteDB extensions
|
/// Requests
|
||||||
[<AutoOpen>]
|
[<Literal>]
|
||||||
module Extensions =
|
let Request = "mpj.request"
|
||||||
|
|
||||||
/// Extensions on the LiteDatabase class
|
|
||||||
type LiteDatabase with
|
|
||||||
|
|
||||||
/// The Request collection
|
|
||||||
member this.Requests = this.GetCollection<Request> "request"
|
|
||||||
|
|
||||||
/// Async version of the checkpoint command (flushes log)
|
|
||||||
member this.SaveChanges () =
|
|
||||||
this.Checkpoint ()
|
|
||||||
Task.CompletedTask
|
|
||||||
|
|
||||||
|
|
||||||
/// Map domain to LiteDB
|
/// JSON serialization customizations
|
||||||
// It does mapping, but since we're so DU-heavy, this gives us control over the JSON representation
|
|
||||||
[<RequireQualifiedAccess>]
|
[<RequireQualifiedAccess>]
|
||||||
module Mapping =
|
module Json =
|
||||||
|
|
||||||
|
open System.Text.Json.Serialization
|
||||||
|
|
||||||
|
/// Convert a wrapped DU to/from its string representation
|
||||||
|
type WrappedJsonConverter<'T> (wrap : string -> 'T, unwrap : 'T -> string) =
|
||||||
|
inherit JsonConverter<'T> ()
|
||||||
|
override _.Read(reader, _, _) =
|
||||||
|
wrap (reader.GetString ())
|
||||||
|
override _.Write(writer, value, _) =
|
||||||
|
writer.WriteStringValue (unwrap value)
|
||||||
|
|
||||||
|
open System.Text.Json
|
||||||
|
open NodaTime.Serialization.SystemTextJson
|
||||||
|
|
||||||
|
/// JSON serializer options to support the target domain
|
||||||
|
let options =
|
||||||
|
let opts = JsonSerializerOptions ()
|
||||||
|
[ WrappedJsonConverter (Recurrence.ofString, Recurrence.toString) :> JsonConverter
|
||||||
|
WrappedJsonConverter (RequestAction.ofString, RequestAction.toString)
|
||||||
|
WrappedJsonConverter (RequestId.ofString, RequestId.toString)
|
||||||
|
WrappedJsonConverter (UserId, UserId.toString)
|
||||||
|
JsonFSharpConverter ()
|
||||||
|
]
|
||||||
|
|> List.iter opts.Converters.Add
|
||||||
|
let _ = opts.ConfigureForNodaTime NodaTime.DateTimeZoneProviders.Tzdb
|
||||||
|
opts.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase
|
||||||
|
opts.DefaultIgnoreCondition <- JsonIgnoreCondition.WhenWritingNull
|
||||||
|
opts
|
||||||
|
|
||||||
|
|
||||||
|
open BitBadger.Npgsql.FSharp.Documents
|
||||||
|
|
||||||
|
/// Connection
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Connection =
|
||||||
|
|
||||||
|
open BitBadger.Npgsql.Documents
|
||||||
|
open Microsoft.Extensions.Configuration
|
||||||
|
open Npgsql
|
||||||
|
open System.Text.Json
|
||||||
|
|
||||||
|
/// Ensure the database is ready to use
|
||||||
|
let private ensureDb () = backgroundTask {
|
||||||
|
do! Custom.nonQuery "CREATE SCHEMA IF NOT EXISTS mpj" []
|
||||||
|
do! Definition.ensureTable Table.Request
|
||||||
|
do! Definition.ensureIndex Table.Request Optimized
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set up the data environment
|
||||||
|
let setUp (cfg : IConfiguration) = backgroundTask {
|
||||||
|
let builder = NpgsqlDataSourceBuilder (cfg.GetConnectionString "mpj")
|
||||||
|
let _ = builder.UseNodaTime ()
|
||||||
|
Configuration.useDataSource (builder.Build ())
|
||||||
|
Configuration.useSerializer
|
||||||
|
{ new IDocumentSerializer with
|
||||||
|
member _.Serialize<'T> (it : 'T) = JsonSerializer.Serialize (it, Json.options)
|
||||||
|
member _.Deserialize<'T> (it : string) = JsonSerializer.Deserialize<'T> (it, Json.options)
|
||||||
|
}
|
||||||
|
do! ensureDb ()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Data access functions for requests
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Request =
|
||||||
|
|
||||||
open NodaTime
|
open NodaTime
|
||||||
open NodaTime.Text
|
|
||||||
|
|
||||||
/// A NodaTime instant pattern to use for parsing instants from the database
|
/// Add a request
|
||||||
let instantPattern = InstantPattern.CreateWithInvariantCulture "g"
|
let add req = backgroundTask {
|
||||||
|
do! insert Table.Request (RequestId.toString req.Id) req
|
||||||
|
}
|
||||||
|
|
||||||
/// Mapping for NodaTime's Instant type
|
/// Does a request exist for the given request ID and user ID?
|
||||||
module Instant =
|
let existsById (reqId : RequestId) (userId : UserId) =
|
||||||
let fromBson (value : BsonValue) = (instantPattern.Parse value.AsString).Value
|
Exists.byContains Table.Request {| Id = reqId; UserId = userId |}
|
||||||
let toBson (value : Instant) : BsonValue = value.ToString ("g", null)
|
|
||||||
|
|
||||||
/// Mapping for option types
|
/// Retrieve a request by its ID and user ID
|
||||||
module Option =
|
let tryById reqId userId = backgroundTask {
|
||||||
let instantFromBson (value : BsonValue) = if value.IsNull then None else Some (Instant.fromBson value)
|
match! Find.byId<Request> Table.Request (RequestId.toString reqId) with
|
||||||
let instantToBson (value : Instant option) = match value with Some it -> Instant.toBson it | None -> null
|
| Some req when req.UserId = userId -> return Some req
|
||||||
|
| _ -> return None
|
||||||
|
}
|
||||||
|
|
||||||
let stringFromBson (value : BsonValue) = match value.AsString with "" -> None | x -> Some x
|
/// Update recurrence for a request
|
||||||
let stringToBson (value : string option) : BsonValue = match value with Some txt -> txt | None -> ""
|
let updateRecurrence reqId userId (recurType : Recurrence) = backgroundTask {
|
||||||
|
let dbId = RequestId.toString reqId
|
||||||
|
match! existsById reqId userId with
|
||||||
|
| true -> do! Update.partialById Table.Request dbId {| Recurrence = recurType |}
|
||||||
|
| false -> invalidOp "Request ID {dbId} not found"
|
||||||
|
}
|
||||||
|
|
||||||
/// Mapping for Recurrence
|
/// Update the show-after time for a request
|
||||||
module Recurrence =
|
let updateShowAfter reqId userId (showAfter : Instant option) = backgroundTask {
|
||||||
let fromBson (value : BsonValue) = Recurrence.ofString value
|
let dbId = RequestId.toString reqId
|
||||||
let toBson (value : Recurrence) : BsonValue = Recurrence.toString value
|
match! existsById reqId userId with
|
||||||
|
| true -> do! Update.partialById Table.Request dbId {| ShowAfter = showAfter |}
|
||||||
|
| false -> invalidOp "Request ID {dbId} not found"
|
||||||
|
}
|
||||||
|
|
||||||
/// Mapping for RequestAction
|
/// Update the snoozed and show-after values for a request
|
||||||
module RequestAction =
|
let updateSnoozed reqId userId (until : Instant option) = backgroundTask {
|
||||||
let fromBson (value : BsonValue) = RequestAction.ofString value.AsString
|
let dbId = RequestId.toString reqId
|
||||||
let toBson (value : RequestAction) : BsonValue = RequestAction.toString value
|
match! existsById reqId userId with
|
||||||
|
| true -> do! Update.partialById Table.Request dbId {| SnoozedUntil = until; ShowAfter = until |}
|
||||||
/// Mapping for RequestId
|
| false -> invalidOp "Request ID {dbId} not found"
|
||||||
module RequestId =
|
}
|
||||||
let fromBson (value : BsonValue) = RequestId.ofString value.AsString
|
|
||||||
let toBson (value : RequestId) : BsonValue = RequestId.toString value
|
|
||||||
|
|
||||||
/// Mapping for UserId
|
|
||||||
module UserId =
|
|
||||||
let fromBson (value : BsonValue) = UserId value.AsString
|
|
||||||
let toBson (value : UserId) : BsonValue = UserId.toString value
|
|
||||||
|
|
||||||
/// Set up the mapping
|
|
||||||
let register () =
|
|
||||||
BsonMapper.Global.RegisterType<Instant>(Instant.toBson, Instant.fromBson)
|
|
||||||
BsonMapper.Global.RegisterType<Instant option>(Option.instantToBson, Option.instantFromBson)
|
|
||||||
BsonMapper.Global.RegisterType<Recurrence>(Recurrence.toBson, Recurrence.fromBson)
|
|
||||||
BsonMapper.Global.RegisterType<RequestAction>(RequestAction.toBson, RequestAction.fromBson)
|
|
||||||
BsonMapper.Global.RegisterType<RequestId>(RequestId.toBson, RequestId.fromBson)
|
|
||||||
BsonMapper.Global.RegisterType<string option>(Option.stringToBson, Option.stringFromBson)
|
|
||||||
BsonMapper.Global.RegisterType<UserId>(UserId.toBson, UserId.fromBson)
|
|
||||||
|
|
||||||
/// Code to be run at startup
|
|
||||||
module Startup =
|
|
||||||
|
|
||||||
/// Ensure the database is set up
|
|
||||||
let ensureDb (db : LiteDatabase) =
|
|
||||||
db.Requests.EnsureIndex (fun it -> it.UserId) |> ignore
|
|
||||||
Mapping.register ()
|
|
||||||
|
|
||||||
|
|
||||||
/// Async wrappers for LiteDB, and request -> journal mappings
|
/// Specific manipulation of history entries
|
||||||
[<AutoOpen>]
|
[<RequireQualifiedAccess>]
|
||||||
module private Helpers =
|
module History =
|
||||||
|
|
||||||
open System.Linq
|
/// Add a history entry
|
||||||
|
let add reqId userId hist = backgroundTask {
|
||||||
/// Convert a sequence to a list asynchronously (used for LiteDB IO)
|
let dbId = RequestId.toString reqId
|
||||||
let toListAsync<'T> (q : 'T seq) =
|
match! Request.tryById reqId userId with
|
||||||
(q.ToList >> Task.FromResult) ()
|
| Some req ->
|
||||||
|
do! Update.partialById Table.Request dbId
|
||||||
/// Convert a sequence to a list asynchronously (used for LiteDB IO)
|
{| History = (hist :: req.History) |> List.sortByDescending (fun it -> it.AsOf) |}
|
||||||
let firstAsync<'T> (q : 'T seq) =
|
| None -> invalidOp $"Request ID {dbId} not found"
|
||||||
q.FirstOrDefault () |> Task.FromResult
|
}
|
||||||
|
|
||||||
/// Async wrapper around a request update
|
|
||||||
let doUpdate (db : LiteDatabase) (req : Request) =
|
|
||||||
db.Requests.Update req |> ignore
|
|
||||||
Task.CompletedTask
|
|
||||||
|
|
||||||
|
|
||||||
/// Retrieve a request, including its history and notes, by its ID and user ID
|
/// Data access functions for journal-style requests
|
||||||
let tryFullRequestById reqId userId (db : LiteDatabase) = backgroundTask {
|
[<RequireQualifiedAccess>]
|
||||||
let! req = db.Requests.Find (Query.EQ ("_id", RequestId.toString reqId)) |> firstAsync
|
module Journal =
|
||||||
return match box req with null -> None | _ when req.UserId = userId -> Some req | _ -> None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a history entry
|
/// Retrieve a user's answered requests
|
||||||
let addHistory reqId userId hist db = backgroundTask {
|
let answered (userId : UserId) = backgroundTask {
|
||||||
match! tryFullRequestById reqId userId db with
|
let! reqs =
|
||||||
| Some req -> do! doUpdate db { req with History = Array.append [| hist |] req.History }
|
Custom.list
|
||||||
| None -> invalidOp $"{RequestId.toString reqId} not found"
|
$"""{Query.Find.byContains Table.Request} AND {Query.whereJsonPathMatches "@stat"}"""
|
||||||
}
|
[ "@criteria", Query.jsonbDocParam {| UserId = userId |}
|
||||||
|
"@stat", Sql.string """$.history[0].status ? (@ == "Answered")"""
|
||||||
|
] fromData<Request>
|
||||||
|
return
|
||||||
|
reqs
|
||||||
|
|> Seq.ofList
|
||||||
|
|> Seq.map JournalRequest.ofRequestLite
|
||||||
|
|> Seq.filter (fun it -> it.LastStatus = Answered)
|
||||||
|
|> Seq.sortByDescending (fun it -> it.AsOf)
|
||||||
|
|> List.ofSeq
|
||||||
|
}
|
||||||
|
|
||||||
/// Add a note
|
/// Retrieve a user's current prayer journal (includes snoozed and non-immediate recurrence)
|
||||||
let addNote reqId userId note db = backgroundTask {
|
let forUser (userId : UserId) = backgroundTask {
|
||||||
match! tryFullRequestById reqId userId db with
|
let! reqs =
|
||||||
| Some req -> do! doUpdate db { req with Notes = Array.append [| note |] req.Notes }
|
Custom.list
|
||||||
| None -> invalidOp $"{RequestId.toString reqId} not found"
|
$"""{Query.Find.byContains Table.Request} AND {Query.whereJsonPathMatches "@stat"}"""
|
||||||
}
|
[ "@criteria", Query.jsonbDocParam {| UserId = userId |}
|
||||||
|
"@stat", Sql.string """$.history[0].status ? (@ <> "Answered")"""
|
||||||
|
] fromData<Request>
|
||||||
|
return
|
||||||
|
reqs
|
||||||
|
|> Seq.ofList
|
||||||
|
|> Seq.map JournalRequest.ofRequestLite
|
||||||
|
|> Seq.filter (fun it -> it.LastStatus <> Answered)
|
||||||
|
|> Seq.sortBy (fun it -> it.AsOf)
|
||||||
|
|> List.ofSeq
|
||||||
|
}
|
||||||
|
|
||||||
/// Add a request
|
/// Does the user's journal have any snoozed requests?
|
||||||
let addRequest (req : Request) (db : LiteDatabase) =
|
let hasSnoozed userId now = backgroundTask {
|
||||||
db.Requests.Insert req |> ignore
|
let! jrnl = forUser userId
|
||||||
|
return jrnl |> List.exists (fun r -> defaultArg (r.SnoozedUntil |> Option.map (fun it -> it > now)) false)
|
||||||
|
}
|
||||||
|
|
||||||
/// Find all requests for the given user
|
let tryById reqId userId = backgroundTask {
|
||||||
let private getRequestsForUser (userId : UserId) (db : LiteDatabase) = backgroundTask {
|
let! req = Request.tryById reqId userId
|
||||||
return! db.Requests.Find (Query.EQ (nameof Request.empty.UserId, Mapping.UserId.toBson userId)) |> toListAsync
|
return req |> Option.map JournalRequest.ofRequestLite
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve all answered requests for the given user
|
|
||||||
let answeredRequests userId db = backgroundTask {
|
|
||||||
let! reqs = getRequestsForUser userId db
|
|
||||||
return
|
|
||||||
reqs
|
|
||||||
|> Seq.map JournalRequest.ofRequestFull
|
|
||||||
|> Seq.filter (fun it -> it.LastStatus = Answered)
|
|
||||||
|> Seq.sortByDescending (fun it -> it.AsOf)
|
|
||||||
|> List.ofSeq
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieve the user's current journal
|
/// Specific manipulation of note entries
|
||||||
let journalByUserId userId db = backgroundTask {
|
[<RequireQualifiedAccess>]
|
||||||
let! reqs = getRequestsForUser userId db
|
module Note =
|
||||||
return
|
|
||||||
reqs
|
|
||||||
|> Seq.map JournalRequest.ofRequestLite
|
|
||||||
|> Seq.filter (fun it -> it.LastStatus <> Answered)
|
|
||||||
|> Seq.sortBy (fun it -> it.AsOf)
|
|
||||||
|> List.ofSeq
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Does the user have any snoozed requests?
|
/// Add a note
|
||||||
let hasSnoozed userId now (db : LiteDatabase) = backgroundTask {
|
let add reqId userId note = backgroundTask {
|
||||||
let! jrnl = journalByUserId userId db
|
let dbId = RequestId.toString reqId
|
||||||
return jrnl |> List.exists (fun r -> defaultArg (r.SnoozedUntil |> Option.map (fun it -> it > now)) false)
|
match! Request.tryById reqId userId with
|
||||||
}
|
| Some req ->
|
||||||
|
do! Update.partialById Table.Request dbId
|
||||||
|
{| Notes = (note :: req.Notes) |> List.sortByDescending (fun it -> it.AsOf) |}
|
||||||
|
| None -> invalidOp $"Request ID {dbId} not found"
|
||||||
|
}
|
||||||
|
|
||||||
/// Retrieve a request by its ID and user ID (without notes and history)
|
/// Retrieve notes for a request by the request ID
|
||||||
let tryRequestById reqId userId db = backgroundTask {
|
let byRequestId reqId userId = backgroundTask {
|
||||||
let! req = tryFullRequestById reqId userId db
|
match! Request.tryById reqId userId with Some req -> return req.Notes | None -> return []
|
||||||
return req |> Option.map (fun r -> { r with History = [||]; Notes = [||] })
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieve notes for a request by its ID and user ID
|
|
||||||
let notesById reqId userId (db : LiteDatabase) = backgroundTask {
|
|
||||||
match! tryFullRequestById reqId userId db with | Some req -> return req.Notes | None -> return [||]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieve a journal request by its ID and user ID
|
|
||||||
let tryJournalById reqId userId (db : LiteDatabase) = backgroundTask {
|
|
||||||
let! req = tryFullRequestById reqId userId db
|
|
||||||
return req |> Option.map JournalRequest.ofRequestLite
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the recurrence for a request
|
|
||||||
let updateRecurrence reqId userId recurType db = backgroundTask {
|
|
||||||
match! tryFullRequestById reqId userId db with
|
|
||||||
| Some req -> do! doUpdate db { req with Recurrence = recurType }
|
|
||||||
| None -> invalidOp $"{RequestId.toString reqId} not found"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update a snoozed request
|
|
||||||
let updateSnoozed reqId userId until db = backgroundTask {
|
|
||||||
match! tryFullRequestById reqId userId db with
|
|
||||||
| Some req -> do! doUpdate db { req with SnoozedUntil = until; ShowAfter = until }
|
|
||||||
| None -> invalidOp $"{RequestId.toString reqId} not found"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the "show after" timestamp for a request
|
|
||||||
let updateShowAfter reqId userId showAfter db = backgroundTask {
|
|
||||||
match! tryFullRequestById reqId userId db with
|
|
||||||
| Some req -> do! doUpdate db { req with ShowAfter = showAfter }
|
|
||||||
| None -> invalidOp $"{RequestId.toString reqId} not found"
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/// The data model for myPrayerJournal
|
/// The data model for myPrayerJournal
|
||||||
[<AutoOpen>]
|
[<AutoOpen>]
|
||||||
module MyPrayerJournal.Domain
|
module MyPrayerJournal.Domain
|
||||||
|
|
||||||
@@ -169,10 +169,10 @@ type Request =
|
|||||||
Recurrence : Recurrence
|
Recurrence : Recurrence
|
||||||
|
|
||||||
/// The history entries for this request
|
/// The history entries for this request
|
||||||
History : History[]
|
History : History list
|
||||||
|
|
||||||
/// The notes for this request
|
/// The notes for this request
|
||||||
Notes : Note[]
|
Notes : Note list
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Functions to support requests
|
/// Functions to support requests
|
||||||
@@ -186,8 +186,8 @@ module Request =
|
|||||||
SnoozedUntil = None
|
SnoozedUntil = None
|
||||||
ShowAfter = None
|
ShowAfter = None
|
||||||
Recurrence = Immediate
|
Recurrence = Immediate
|
||||||
History = [||]
|
History = []
|
||||||
Notes = [||]
|
Notes = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -234,7 +234,8 @@ module JournalRequest =
|
|||||||
|
|
||||||
/// Convert a request to the form used for the journal (precomputed values, no notes or history)
|
/// Convert a request to the form used for the journal (precomputed values, no notes or history)
|
||||||
let ofRequestLite (req : Request) =
|
let ofRequestLite (req : Request) =
|
||||||
let lastHistory = req.History |> Array.sortByDescending (fun it -> it.AsOf) |> Array.tryHead
|
let history = Seq.ofList req.History
|
||||||
|
let lastHistory = Seq.tryHead history
|
||||||
// Requests are sorted by the "as of" field in this record; for sorting to work properly, we will put the
|
// Requests are sorted by the "as of" field in this record; for sorting to work properly, we will put the
|
||||||
// largest of the last prayed date, the "snoozed until". or the "show after" date; if none of those are filled,
|
// largest of the last prayed date, the "snoozed until". or the "show after" date; if none of those are filled,
|
||||||
// we will use the last activity date. This will mean that:
|
// we will use the last activity date. This will mean that:
|
||||||
@@ -247,19 +248,17 @@ module JournalRequest =
|
|||||||
let showAfter = defaultArg req.ShowAfter Instant.MinValue
|
let showAfter = defaultArg req.ShowAfter Instant.MinValue
|
||||||
let snoozedUntil = defaultArg req.SnoozedUntil Instant.MinValue
|
let snoozedUntil = defaultArg req.SnoozedUntil Instant.MinValue
|
||||||
let lastPrayed =
|
let lastPrayed =
|
||||||
req.History
|
history
|
||||||
|> Array.sortByDescending (fun it -> it.AsOf)
|
|> Seq.filter History.isPrayed
|
||||||
|> Array.filter History.isPrayed
|
|> Seq.tryHead
|
||||||
|> Array.tryHead
|
|
||||||
|> Option.map (fun it -> it.AsOf)
|
|> Option.map (fun it -> it.AsOf)
|
||||||
|> Option.defaultValue Instant.MinValue
|
|> Option.defaultValue Instant.MinValue
|
||||||
let asOf = List.max [ lastPrayed; showAfter; snoozedUntil ]
|
let asOf = List.max [ lastPrayed; showAfter; snoozedUntil ]
|
||||||
{ RequestId = req.Id
|
{ RequestId = req.Id
|
||||||
UserId = req.UserId
|
UserId = req.UserId
|
||||||
Text = req.History
|
Text = history
|
||||||
|> Array.filter (fun it -> Option.isSome it.Text)
|
|> Seq.filter (fun it -> Option.isSome it.Text)
|
||||||
|> Array.sortByDescending (fun it -> it.AsOf)
|
|> Seq.tryHead
|
||||||
|> Array.tryHead
|
|
||||||
|> Option.map (fun h -> Option.get h.Text)
|
|> Option.map (fun h -> Option.get h.Text)
|
||||||
|> Option.defaultValue ""
|
|> Option.defaultValue ""
|
||||||
AsOf = if asOf > Instant.MinValue then asOf else lastActivity
|
AsOf = if asOf > Instant.MinValue then asOf else lastActivity
|
||||||
@@ -275,6 +274,6 @@ module JournalRequest =
|
|||||||
/// Same as `ofRequestLite`, but with notes and history
|
/// Same as `ofRequestLite`, but with notes and history
|
||||||
let ofRequestFull req =
|
let ofRequestFull req =
|
||||||
{ ofRequestLite req with
|
{ ofRequestLite req with
|
||||||
History = List.ofArray req.History
|
History = req.History
|
||||||
Notes = List.ofArray req.Notes
|
Notes = req.Notes
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/// HTTP handlers for the myPrayerJournal API
|
/// HTTP handlers for the myPrayerJournal API
|
||||||
[<RequireQualifiedAccess>]
|
[<RequireQualifiedAccess>]
|
||||||
module MyPrayerJournal.Handlers
|
module MyPrayerJournal.Handlers
|
||||||
|
|
||||||
@@ -45,16 +45,12 @@ module Error =
|
|||||||
|
|
||||||
|
|
||||||
open System.Security.Claims
|
open System.Security.Claims
|
||||||
open LiteDB
|
|
||||||
open Microsoft.AspNetCore.Http
|
open Microsoft.AspNetCore.Http
|
||||||
open NodaTime
|
open NodaTime
|
||||||
|
|
||||||
/// Extensions on the HTTP context
|
/// Extensions on the HTTP context
|
||||||
type HttpContext with
|
type HttpContext with
|
||||||
|
|
||||||
/// The LiteDB database
|
|
||||||
member this.Db = this.GetService<LiteDatabase> ()
|
|
||||||
|
|
||||||
/// The "sub" for the current user (None if no user is authenticated)
|
/// The "sub" for the current user (None if no user is authenticated)
|
||||||
member this.CurrentUser =
|
member this.CurrentUser =
|
||||||
this.User
|
this.User
|
||||||
@@ -83,6 +79,8 @@ type HttpContext with
|
|||||||
| None -> DateTimeZone.Utc
|
| None -> DateTimeZone.Utc
|
||||||
|
|
||||||
|
|
||||||
|
open MyPrayerJournal.Data
|
||||||
|
|
||||||
/// Handler helpers
|
/// Handler helpers
|
||||||
[<AutoOpen>]
|
[<AutoOpen>]
|
||||||
module private Helpers =
|
module private Helpers =
|
||||||
@@ -127,7 +125,7 @@ module private Helpers =
|
|||||||
let pageContext (ctx : HttpContext) pageTitle content = backgroundTask {
|
let pageContext (ctx : HttpContext) pageTitle content = backgroundTask {
|
||||||
let! hasSnoozed =
|
let! hasSnoozed =
|
||||||
match ctx.CurrentUser with
|
match ctx.CurrentUser with
|
||||||
| Some _ -> Data.hasSnoozed ctx.UserId (ctx.Now ()) ctx.Db
|
| Some _ -> Journal.hasSnoozed ctx.UserId (ctx.Now ())
|
||||||
| None -> Task.FromResult false
|
| None -> Task.FromResult false
|
||||||
return
|
return
|
||||||
{ IsAuthenticated = Option.isSome ctx.CurrentUser
|
{ IsAuthenticated = Option.isSome ctx.CurrentUser
|
||||||
@@ -155,17 +153,17 @@ module private Helpers =
|
|||||||
|
|
||||||
/// Push a new message into the list
|
/// Push a new message into the list
|
||||||
let push (ctx : HttpContext) message url = lock upd8 (fun () ->
|
let push (ctx : HttpContext) message url = lock upd8 (fun () ->
|
||||||
messages <- messages.Add (ctx.UserId, (message, url)))
|
messages <- messages.Add (ctx.UserId, (message, url)))
|
||||||
|
|
||||||
/// Add a success message header to the response
|
/// Add a success message header to the response
|
||||||
let pushSuccess ctx message url =
|
let pushSuccess ctx message url =
|
||||||
push ctx $"success|||%s{message}" url
|
push ctx $"success|||%s{message}" url
|
||||||
|
|
||||||
/// Pop the messages for the given user
|
/// Pop the messages for the given user
|
||||||
let pop userId = lock upd8 (fun () ->
|
let pop userId = lock upd8 (fun () ->
|
||||||
let msg = messages.TryFind userId
|
let msg = messages.TryFind userId
|
||||||
msg |> Option.iter (fun _ -> messages <- messages.Remove userId)
|
msg |> Option.iter (fun _ -> messages <- messages.Remove userId)
|
||||||
msg)
|
msg)
|
||||||
|
|
||||||
/// Send a partial result if this is not a full page load (does not append no-cache headers)
|
/// Send a partial result if this is not a full page load (does not append no-cache headers)
|
||||||
let partialStatic (pageTitle : string) content : HttpHandler = fun next ctx -> task {
|
let partialStatic (pageTitle : string) content : HttpHandler = fun next ctx -> task {
|
||||||
@@ -238,7 +236,6 @@ module Models =
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
open MyPrayerJournal.Data.Extensions
|
|
||||||
open NodaTime.Text
|
open NodaTime.Text
|
||||||
|
|
||||||
/// Handlers for less-than-full-page HTML requests
|
/// Handlers for less-than-full-page HTML requests
|
||||||
@@ -254,14 +251,14 @@ module Components =
|
|||||||
| Some snooze, _ when snooze < now -> true
|
| Some snooze, _ when snooze < now -> true
|
||||||
| _, Some hide when hide < now -> true
|
| _, Some hide when hide < now -> true
|
||||||
| _, _ -> false
|
| _, _ -> false
|
||||||
let! journal = Data.journalByUserId ctx.UserId ctx.Db
|
let! journal = Journal.forUser ctx.UserId
|
||||||
let shown = journal |> List.filter shouldBeShown
|
let shown = journal |> List.filter shouldBeShown
|
||||||
return! renderComponent [ Views.Journal.journalItems now ctx.TimeZone shown ] next ctx
|
return! renderComponent [ Views.Journal.journalItems now ctx.TimeZone shown ] next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /components/request-item/[req-id]
|
// GET /components/request-item/[req-id]
|
||||||
let requestItem reqId : HttpHandler = requireUser >=> fun next ctx -> task {
|
let requestItem reqId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||||
match! Data.tryJournalById (RequestId.ofString reqId) ctx.UserId ctx.Db with
|
match! Journal.tryById (RequestId.ofString reqId) ctx.UserId with
|
||||||
| Some req -> return! renderComponent [ Views.Request.reqListItem (ctx.Now ()) ctx.TimeZone req ] next ctx
|
| Some req -> return! renderComponent [ Views.Request.reqListItem (ctx.Now ()) ctx.TimeZone req ] next ctx
|
||||||
| None -> return! Error.notFound next ctx
|
| None -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
@@ -272,8 +269,8 @@ module Components =
|
|||||||
|
|
||||||
// GET /components/request/[req-id]/notes
|
// GET /components/request/[req-id]/notes
|
||||||
let notes requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
let notes requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||||
let! notes = Data.notesById (RequestId.ofString requestId) ctx.UserId ctx.Db
|
let! notes = Note.byRequestId (RequestId.ofString requestId) ctx.UserId
|
||||||
return! renderComponent (Views.Request.notes (ctx.Now ()) ctx.TimeZone (List.ofArray notes)) next ctx
|
return! renderComponent (Views.Request.notes (ctx.Now ()) ctx.TimeZone notes) next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /components/request/[req-id]/snooze
|
// GET /components/request/[req-id]/snooze
|
||||||
@@ -333,7 +330,7 @@ module Request =
|
|||||||
return! partial "Add Prayer Request"
|
return! partial "Add Prayer Request"
|
||||||
(Views.Request.edit (JournalRequest.ofRequestLite Request.empty) returnTo true) next ctx
|
(Views.Request.edit (JournalRequest.ofRequestLite Request.empty) returnTo true) next ctx
|
||||||
| _ ->
|
| _ ->
|
||||||
match! Data.tryJournalById (RequestId.ofString requestId) ctx.UserId ctx.Db with
|
match! Journal.tryById (RequestId.ofString requestId) ctx.UserId with
|
||||||
| Some req ->
|
| Some req ->
|
||||||
debug ctx "Found - sending view"
|
debug ctx "Found - sending view"
|
||||||
return! partial "Edit Prayer Request" (Views.Request.edit req returnTo false) next ctx
|
return! partial "Edit Prayer Request" (Views.Request.edit req returnTo false) next ctx
|
||||||
@@ -344,46 +341,42 @@ module Request =
|
|||||||
|
|
||||||
// PATCH /request/[req-id]/prayed
|
// PATCH /request/[req-id]/prayed
|
||||||
let prayed requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
let prayed requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||||
let db = ctx.Db
|
|
||||||
let userId = ctx.UserId
|
let userId = ctx.UserId
|
||||||
let reqId = RequestId.ofString requestId
|
let reqId = RequestId.ofString requestId
|
||||||
match! Data.tryRequestById reqId userId db with
|
match! Journal.tryById reqId userId with
|
||||||
| Some req ->
|
| Some req ->
|
||||||
let now = ctx.Now ()
|
let now = ctx.Now ()
|
||||||
do! Data.addHistory reqId userId { AsOf = now; Status = Prayed; Text = None } db
|
do! History.add reqId userId { AsOf = now; Status = Prayed; Text = None }
|
||||||
let nextShow =
|
let nextShow =
|
||||||
match Recurrence.duration req.Recurrence with
|
match Recurrence.duration req.Recurrence with
|
||||||
| 0L -> None
|
| 0L -> None
|
||||||
| duration -> Some <| now.Plus (Duration.FromSeconds duration)
|
| duration -> Some <| now.Plus (Duration.FromSeconds duration)
|
||||||
do! Data.updateShowAfter reqId userId nextShow db
|
do! Request.updateShowAfter reqId userId nextShow
|
||||||
do! db.SaveChanges ()
|
|
||||||
return! (withSuccessMessage "Request marked as prayed" >=> Components.journalItems) next ctx
|
return! (withSuccessMessage "Request marked as prayed" >=> Components.journalItems) next ctx
|
||||||
| None -> return! Error.notFound next ctx
|
| None -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /request/[req-id]/note
|
// POST /request/[req-id]/note
|
||||||
let addNote requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
let addNote requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||||
let db = ctx.Db
|
|
||||||
let userId = ctx.UserId
|
let userId = ctx.UserId
|
||||||
let reqId = RequestId.ofString requestId
|
let reqId = RequestId.ofString requestId
|
||||||
match! Data.tryRequestById reqId userId db with
|
match! Request.existsById reqId userId with
|
||||||
| Some _ ->
|
| true ->
|
||||||
let! notes = ctx.BindFormAsync<Models.NoteEntry> ()
|
let! notes = ctx.BindFormAsync<Models.NoteEntry> ()
|
||||||
do! Data.addNote reqId userId { AsOf = ctx.Now (); Notes = notes.notes } db
|
do! Note.add reqId userId { AsOf = ctx.Now (); Notes = notes.notes }
|
||||||
do! db.SaveChanges ()
|
|
||||||
return! (withSuccessMessage "Added Notes" >=> hideModal "notes" >=> created) next ctx
|
return! (withSuccessMessage "Added Notes" >=> hideModal "notes" >=> created) next ctx
|
||||||
| None -> return! Error.notFound next ctx
|
| false -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /requests/active
|
// GET /requests/active
|
||||||
let active : HttpHandler = requireUser >=> fun next ctx -> task {
|
let active : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||||
let! reqs = Data.journalByUserId ctx.UserId ctx.Db
|
let! reqs = Journal.forUser ctx.UserId
|
||||||
return! partial "Active Requests" (Views.Request.active (ctx.Now ()) ctx.TimeZone reqs) next ctx
|
return! partial "Active Requests" (Views.Request.active (ctx.Now ()) ctx.TimeZone reqs) next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /requests/snoozed
|
// GET /requests/snoozed
|
||||||
let snoozed : HttpHandler = requireUser >=> fun next ctx -> task {
|
let snoozed : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||||
let! reqs = Data.journalByUserId ctx.UserId ctx.Db
|
let! reqs = Journal.forUser ctx.UserId
|
||||||
let now = ctx.Now ()
|
let now = ctx.Now ()
|
||||||
let snoozed = reqs
|
let snoozed = reqs
|
||||||
|> List.filter (fun it -> defaultArg (it.SnoozedUntil |> Option.map (fun it -> it > now)) false)
|
|> List.filter (fun it -> defaultArg (it.SnoozedUntil |> Option.map (fun it -> it > now)) false)
|
||||||
@@ -392,62 +385,56 @@ module Request =
|
|||||||
|
|
||||||
// GET /requests/answered
|
// GET /requests/answered
|
||||||
let answered : HttpHandler = requireUser >=> fun next ctx -> task {
|
let answered : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||||
let! reqs = Data.answeredRequests ctx.UserId ctx.Db
|
let! reqs = Journal.answered ctx.UserId
|
||||||
return! partial "Answered Requests" (Views.Request.answered (ctx.Now ()) ctx.TimeZone reqs) next ctx
|
return! partial "Answered Requests" (Views.Request.answered (ctx.Now ()) ctx.TimeZone reqs) next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /request/[req-id]/full
|
// GET /request/[req-id]/full
|
||||||
let getFull requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
let getFull requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||||
match! Data.tryFullRequestById (RequestId.ofString requestId) ctx.UserId ctx.Db with
|
match! Request.tryById (RequestId.ofString requestId) ctx.UserId with
|
||||||
| Some req -> return! partial "Prayer Request" (Views.Request.full ctx.Clock ctx.TimeZone req) next ctx
|
| Some req -> return! partial "Prayer Request" (Views.Request.full ctx.Clock ctx.TimeZone req) next ctx
|
||||||
| None -> return! Error.notFound next ctx
|
| None -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /request/[req-id]/show
|
// PATCH /request/[req-id]/show
|
||||||
let show requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
let show requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||||
let db = ctx.Db
|
|
||||||
let userId = ctx.UserId
|
let userId = ctx.UserId
|
||||||
let reqId = RequestId.ofString requestId
|
let reqId = RequestId.ofString requestId
|
||||||
match! Data.tryRequestById reqId userId db with
|
match! Request.existsById reqId userId with
|
||||||
| Some _ ->
|
| true ->
|
||||||
do! Data.updateShowAfter reqId userId None db
|
do! Request.updateShowAfter reqId userId None
|
||||||
do! db.SaveChanges ()
|
|
||||||
return! (withSuccessMessage "Request now shown" >=> Components.requestItem requestId) next ctx
|
return! (withSuccessMessage "Request now shown" >=> Components.requestItem requestId) next ctx
|
||||||
| None -> return! Error.notFound next ctx
|
| false -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /request/[req-id]/snooze
|
// PATCH /request/[req-id]/snooze
|
||||||
let snooze requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
let snooze requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||||
let db = ctx.Db
|
|
||||||
let userId = ctx.UserId
|
let userId = ctx.UserId
|
||||||
let reqId = RequestId.ofString requestId
|
let reqId = RequestId.ofString requestId
|
||||||
match! Data.tryRequestById reqId userId db with
|
match! Request.existsById reqId userId with
|
||||||
| Some _ ->
|
| true ->
|
||||||
let! until = ctx.BindFormAsync<Models.SnoozeUntil> ()
|
let! until = ctx.BindFormAsync<Models.SnoozeUntil> ()
|
||||||
let date =
|
let date =
|
||||||
LocalDatePattern.CreateWithInvariantCulture("yyyy-MM-dd").Parse(until.until).Value
|
LocalDatePattern.CreateWithInvariantCulture("yyyy-MM-dd").Parse(until.until).Value
|
||||||
.AtStartOfDayInZone(DateTimeZone.Utc)
|
.AtStartOfDayInZone(DateTimeZone.Utc)
|
||||||
.ToInstant ()
|
.ToInstant ()
|
||||||
do! Data.updateSnoozed reqId userId (Some date) db
|
do! Request.updateSnoozed reqId userId (Some date)
|
||||||
do! db.SaveChanges ()
|
|
||||||
return!
|
return!
|
||||||
(withSuccessMessage $"Request snoozed until {until.until}"
|
(withSuccessMessage $"Request snoozed until {until.until}"
|
||||||
>=> hideModal "snooze"
|
>=> hideModal "snooze"
|
||||||
>=> Components.journalItems) next ctx
|
>=> Components.journalItems) next ctx
|
||||||
| None -> return! Error.notFound next ctx
|
| false -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /request/[req-id]/cancel-snooze
|
// PATCH /request/[req-id]/cancel-snooze
|
||||||
let cancelSnooze requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
let cancelSnooze requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||||
let db = ctx.Db
|
|
||||||
let userId = ctx.UserId
|
let userId = ctx.UserId
|
||||||
let reqId = RequestId.ofString requestId
|
let reqId = RequestId.ofString requestId
|
||||||
match! Data.tryRequestById reqId userId db with
|
match! Request.existsById reqId userId with
|
||||||
| Some _ ->
|
| true ->
|
||||||
do! Data.updateSnoozed reqId userId None db
|
do! Request.updateSnoozed reqId userId None
|
||||||
do! db.SaveChanges ()
|
|
||||||
return! (withSuccessMessage "Request unsnoozed" >=> Components.requestItem requestId) next ctx
|
return! (withSuccessMessage "Request unsnoozed" >=> Components.requestItem requestId) next ctx
|
||||||
| None -> return! Error.notFound next ctx
|
| false -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Derive a recurrence from its representation in the form
|
/// Derive a recurrence from its representation in the form
|
||||||
@@ -458,7 +445,6 @@ module Request =
|
|||||||
// POST /request
|
// POST /request
|
||||||
let add : HttpHandler = requireUser >=> fun next ctx -> task {
|
let add : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||||
let! form = ctx.BindModelAsync<Models.Request> ()
|
let! form = ctx.BindModelAsync<Models.Request> ()
|
||||||
let db = ctx.Db
|
|
||||||
let userId = ctx.UserId
|
let userId = ctx.UserId
|
||||||
let now = ctx.Now ()
|
let now = ctx.Now ()
|
||||||
let req =
|
let req =
|
||||||
@@ -468,15 +454,14 @@ module Request =
|
|||||||
EnteredOn = now
|
EnteredOn = now
|
||||||
ShowAfter = None
|
ShowAfter = None
|
||||||
Recurrence = parseRecurrence form
|
Recurrence = parseRecurrence form
|
||||||
History = [|
|
History = [
|
||||||
{ AsOf = now
|
{ AsOf = now
|
||||||
Status = Created
|
Status = Created
|
||||||
Text = Some form.requestText
|
Text = Some form.requestText
|
||||||
}
|
}
|
||||||
|]
|
]
|
||||||
}
|
}
|
||||||
Data.addRequest req db
|
do! Request.add req
|
||||||
do! db.SaveChanges ()
|
|
||||||
Messages.pushSuccess ctx "Added prayer request" "/journal"
|
Messages.pushSuccess ctx "Added prayer request" "/journal"
|
||||||
return! seeOther "/journal" next ctx
|
return! seeOther "/journal" next ctx
|
||||||
}
|
}
|
||||||
@@ -484,25 +469,24 @@ module Request =
|
|||||||
// PATCH /request
|
// PATCH /request
|
||||||
let update : HttpHandler = requireUser >=> fun next ctx -> task {
|
let update : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||||
let! form = ctx.BindModelAsync<Models.Request> ()
|
let! form = ctx.BindModelAsync<Models.Request> ()
|
||||||
let db = ctx.Db
|
|
||||||
let userId = ctx.UserId
|
let userId = ctx.UserId
|
||||||
match! Data.tryJournalById (RequestId.ofString form.requestId) userId db with
|
// TODO: update the instance and save rather than all these little updates
|
||||||
|
match! Journal.tryById (RequestId.ofString form.requestId) userId with
|
||||||
| Some req ->
|
| Some req ->
|
||||||
// update recurrence if changed
|
// update recurrence if changed
|
||||||
let recur = parseRecurrence form
|
let recur = parseRecurrence form
|
||||||
match recur = req.Recurrence with
|
match recur = req.Recurrence with
|
||||||
| true -> ()
|
| true -> ()
|
||||||
| false ->
|
| false ->
|
||||||
do! Data.updateRecurrence req.RequestId userId recur db
|
do! Request.updateRecurrence req.RequestId userId recur
|
||||||
match recur with
|
match recur with
|
||||||
| Immediate -> do! Data.updateShowAfter req.RequestId userId None db
|
| Immediate -> do! Request.updateShowAfter req.RequestId userId None
|
||||||
| _ -> ()
|
| _ -> ()
|
||||||
// append history
|
// append history
|
||||||
let upd8Text = form.requestText.Trim ()
|
let upd8Text = form.requestText.Trim ()
|
||||||
let text = if upd8Text = req.Text then None else Some upd8Text
|
let text = if upd8Text = req.Text then None else Some upd8Text
|
||||||
do! Data.addHistory req.RequestId userId
|
do! History.add req.RequestId userId
|
||||||
{ AsOf = ctx.Now (); Status = (Option.get >> RequestAction.ofString) form.status; Text = text } db
|
{ AsOf = ctx.Now (); Status = (Option.get >> RequestAction.ofString) form.status; Text = text }
|
||||||
do! db.SaveChanges ()
|
|
||||||
let nextUrl =
|
let nextUrl =
|
||||||
match form.returnTo with
|
match form.returnTo with
|
||||||
| "active" -> "/requests/active"
|
| "active" -> "/requests/active"
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net7.0</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
<Version>3.2</Version>
|
<Version>3.3</Version>
|
||||||
<DebugType>embedded</DebugType>
|
<DebugType>embedded</DebugType>
|
||||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||||
<NoWarn>3391</NoWarn>
|
<PublishSingleFile>false</PublishSingleFile>
|
||||||
|
<SelfContained>false</SelfContained>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="Domain.fs" />
|
<Compile Include="Domain.fs" />
|
||||||
@@ -19,15 +20,16 @@
|
|||||||
<Compile Include="Program.fs" />
|
<Compile Include="Program.fs" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FSharp.SystemTextJson" Version="1.1.23" />
|
<PackageReference Include="BitBadger.Npgsql.FSharp.Documents" Version="1.0.0-beta3" />
|
||||||
|
<PackageReference Include="FSharp.SystemTextJson" Version="1.2.42" />
|
||||||
<PackageReference Include="FunctionalCuid" Version="1.0.0" />
|
<PackageReference Include="FunctionalCuid" Version="1.0.0" />
|
||||||
<PackageReference Include="Giraffe" Version="6.0.0" />
|
<PackageReference Include="Giraffe" Version="6.2.0" />
|
||||||
<PackageReference Include="Giraffe.Htmx" Version="1.9.2" />
|
<PackageReference Include="Giraffe.Htmx" Version="1.9.6" />
|
||||||
<PackageReference Include="Giraffe.ViewEngine.Htmx" Version="1.9.2" />
|
<PackageReference Include="Giraffe.ViewEngine.Htmx" Version="1.9.6" />
|
||||||
<PackageReference Include="LiteDB" Version="5.0.16" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="7.0.11" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="7.0.5" />
|
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.1.2" />
|
||||||
<PackageReference Include="NodaTime" Version="3.1.2" />
|
<PackageReference Include="Npgsql.NodaTime" Version="7.0.6" />
|
||||||
<PackageReference Update="FSharp.Core" Version="7.0.300" />
|
<PackageReference Update="FSharp.Core" Version="7.0.400" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="wwwroot\" />
|
<Folder Include="wwwroot\" />
|
||||||
|
|||||||
+105
-163
@@ -1,169 +1,111 @@
|
|||||||
module MyPrayerJournal.Api
|
module MyPrayerJournal.Api
|
||||||
|
|
||||||
|
open Microsoft.AspNetCore.Http
|
||||||
|
|
||||||
|
let sameSite (opts : CookieOptions) =
|
||||||
|
match opts.SameSite, opts.Secure with
|
||||||
|
| SameSiteMode.None, false -> opts.SameSite <- SameSiteMode.Unspecified
|
||||||
|
| _, _ -> ()
|
||||||
|
|
||||||
|
open Giraffe
|
||||||
|
open Giraffe.EndpointRouting
|
||||||
|
open Microsoft.AspNetCore.Authentication.Cookies
|
||||||
|
open Microsoft.AspNetCore.Authentication.OpenIdConnect
|
||||||
open Microsoft.AspNetCore.Builder
|
open Microsoft.AspNetCore.Builder
|
||||||
open Microsoft.AspNetCore.Hosting
|
open Microsoft.AspNetCore.HttpOverrides
|
||||||
open System.IO
|
open Microsoft.Extensions.Configuration
|
||||||
|
open Microsoft.Extensions.DependencyInjection
|
||||||
/// Configuration functions for the application
|
open Microsoft.Extensions.Hosting
|
||||||
module Configure =
|
open Microsoft.Extensions.Logging
|
||||||
|
open Microsoft.IdentityModel.Protocols.OpenIdConnect
|
||||||
/// Configure the content root
|
open MyPrayerJournal.Data
|
||||||
let contentRoot root =
|
open NodaTime
|
||||||
WebApplicationOptions (ContentRootPath = root) |> WebApplication.CreateBuilder
|
open System
|
||||||
|
open System.Text.Json
|
||||||
|
open System.Threading.Tasks
|
||||||
open Microsoft.Extensions.Configuration
|
|
||||||
|
|
||||||
/// Configure the application configuration
|
|
||||||
let appConfiguration (bldr : WebApplicationBuilder) =
|
|
||||||
bldr.Configuration
|
|
||||||
.SetBasePath(bldr.Environment.ContentRootPath)
|
|
||||||
.AddJsonFile("appsettings.json", optional = false, reloadOnChange = true)
|
|
||||||
.AddJsonFile($"appsettings.{bldr.Environment.EnvironmentName}.json", optional = true, reloadOnChange = true)
|
|
||||||
.AddEnvironmentVariables ()
|
|
||||||
|> ignore
|
|
||||||
bldr
|
|
||||||
|
|
||||||
|
|
||||||
open Microsoft.AspNetCore.Server.Kestrel.Core
|
|
||||||
|
|
||||||
/// Configure Kestrel from appsettings.json
|
|
||||||
let kestrel (bldr : WebApplicationBuilder) =
|
|
||||||
let kestrelOpts (ctx : WebHostBuilderContext) (opts : KestrelServerOptions) =
|
|
||||||
(ctx.Configuration.GetSection >> opts.Configure >> ignore) "Kestrel"
|
|
||||||
bldr.WebHost.UseKestrel().ConfigureKestrel kestrelOpts |> ignore
|
|
||||||
bldr
|
|
||||||
|
|
||||||
|
|
||||||
/// Configure the web root directory
|
|
||||||
let webRoot pathSegments (bldr : WebApplicationBuilder) =
|
|
||||||
Array.concat [ [| bldr.Environment.ContentRootPath |]; pathSegments ]
|
|
||||||
|> (Path.Combine >> bldr.WebHost.UseWebRoot >> ignore)
|
|
||||||
bldr
|
|
||||||
|
|
||||||
|
|
||||||
open Microsoft.Extensions.Logging
|
|
||||||
open Microsoft.Extensions.Hosting
|
|
||||||
|
|
||||||
/// Configure logging
|
|
||||||
let logging (bldr : WebApplicationBuilder) =
|
|
||||||
if bldr.Environment.IsDevelopment () then bldr.Logging.AddFilter (fun l -> l > LogLevel.Information) |> ignore
|
|
||||||
bldr.Logging.AddConsole().AddDebug() |> ignore
|
|
||||||
bldr
|
|
||||||
|
|
||||||
|
|
||||||
open Giraffe
|
|
||||||
open LiteDB
|
|
||||||
open Microsoft.AspNetCore.Authentication.Cookies
|
|
||||||
open Microsoft.AspNetCore.Authentication.OpenIdConnect
|
|
||||||
open Microsoft.AspNetCore.Http
|
|
||||||
open Microsoft.Extensions.DependencyInjection
|
|
||||||
open Microsoft.IdentityModel.Protocols.OpenIdConnect
|
|
||||||
open NodaTime
|
|
||||||
open System
|
|
||||||
open System.Text.Json
|
|
||||||
open System.Text.Json.Serialization
|
|
||||||
open System.Threading.Tasks
|
|
||||||
|
|
||||||
/// Configure dependency injection
|
|
||||||
let services (bldr : WebApplicationBuilder) =
|
|
||||||
let sameSite (opts : CookieOptions) =
|
|
||||||
match opts.SameSite, opts.Secure with
|
|
||||||
| SameSiteMode.None, false -> opts.SameSite <- SameSiteMode.Unspecified
|
|
||||||
| _, _ -> ()
|
|
||||||
|
|
||||||
let _ = bldr.Services.AddRouting ()
|
|
||||||
let _ = bldr.Services.AddGiraffe ()
|
|
||||||
let _ = bldr.Services.AddSingleton<IClock> SystemClock.Instance
|
|
||||||
let _ = bldr.Services.AddSingleton<IDateTimeZoneProvider> DateTimeZoneProviders.Tzdb
|
|
||||||
|
|
||||||
let _ =
|
|
||||||
bldr.Services.Configure<CookiePolicyOptions>(fun (opts : CookiePolicyOptions) ->
|
|
||||||
opts.MinimumSameSitePolicy <- SameSiteMode.Unspecified
|
|
||||||
opts.OnAppendCookie <- fun ctx -> sameSite ctx.CookieOptions
|
|
||||||
opts.OnDeleteCookie <- fun ctx -> sameSite ctx.CookieOptions)
|
|
||||||
let _ =
|
|
||||||
bldr.Services.AddAuthentication(fun opts ->
|
|
||||||
opts.DefaultAuthenticateScheme <- CookieAuthenticationDefaults.AuthenticationScheme
|
|
||||||
opts.DefaultSignInScheme <- CookieAuthenticationDefaults.AuthenticationScheme
|
|
||||||
opts.DefaultChallengeScheme <- CookieAuthenticationDefaults.AuthenticationScheme)
|
|
||||||
.AddCookie()
|
|
||||||
.AddOpenIdConnect("Auth0", fun opts ->
|
|
||||||
// Configure OIDC with Auth0 options from configuration
|
|
||||||
let cfg = bldr.Configuration.GetSection "Auth0"
|
|
||||||
opts.Authority <- $"""https://{cfg["Domain"]}/"""
|
|
||||||
opts.ClientId <- cfg["Id"]
|
|
||||||
opts.ClientSecret <- cfg["Secret"]
|
|
||||||
opts.ResponseType <- OpenIdConnectResponseType.Code
|
|
||||||
|
|
||||||
opts.Scope.Clear ()
|
|
||||||
opts.Scope.Add "openid"
|
|
||||||
opts.Scope.Add "profile"
|
|
||||||
|
|
||||||
opts.CallbackPath <- PathString "/user/log-on/success"
|
|
||||||
opts.ClaimsIssuer <- "Auth0"
|
|
||||||
opts.SaveTokens <- true
|
|
||||||
|
|
||||||
opts.Events <- OpenIdConnectEvents ()
|
|
||||||
opts.Events.OnRedirectToIdentityProviderForSignOut <- fun ctx ->
|
|
||||||
let returnTo =
|
|
||||||
match ctx.Properties.RedirectUri with
|
|
||||||
| it when isNull it || it = "" -> ""
|
|
||||||
| redirUri ->
|
|
||||||
let finalRedirUri =
|
|
||||||
match redirUri.StartsWith "/" with
|
|
||||||
| true ->
|
|
||||||
// transform to absolute
|
|
||||||
let request = ctx.Request
|
|
||||||
$"{request.Scheme}://{request.Host.Value}{request.PathBase.Value}{redirUri}"
|
|
||||||
| false -> redirUri
|
|
||||||
Uri.EscapeDataString $"&returnTo={finalRedirUri}"
|
|
||||||
ctx.Response.Redirect $"""https://{cfg["Domain"]}/v2/logout?client_id={cfg["Id"]}{returnTo}"""
|
|
||||||
ctx.HandleResponse ()
|
|
||||||
Task.CompletedTask
|
|
||||||
opts.Events.OnRedirectToIdentityProvider <- fun ctx ->
|
|
||||||
let bldr = UriBuilder ctx.ProtocolMessage.RedirectUri
|
|
||||||
bldr.Scheme <- cfg["Scheme"]
|
|
||||||
bldr.Port <- int cfg["Port"]
|
|
||||||
ctx.ProtocolMessage.RedirectUri <- string bldr
|
|
||||||
Task.CompletedTask)
|
|
||||||
|
|
||||||
let jsonOptions = JsonSerializerOptions ()
|
|
||||||
jsonOptions.Converters.Add (JsonFSharpConverter ())
|
|
||||||
let db = new LiteDatabase (bldr.Configuration.GetConnectionString "db")
|
|
||||||
Data.Startup.ensureDb db
|
|
||||||
let _ = bldr.Services.AddSingleton jsonOptions
|
|
||||||
let _ = bldr.Services.AddSingleton<Json.ISerializer, SystemTextJson.Serializer> ()
|
|
||||||
let _ = bldr.Services.AddSingleton<LiteDatabase> db
|
|
||||||
|
|
||||||
bldr.Build ()
|
|
||||||
|
|
||||||
|
|
||||||
open Giraffe.EndpointRouting
|
|
||||||
|
|
||||||
/// Configure the web application
|
|
||||||
let application (app : WebApplication) =
|
|
||||||
let _ = app.UseStaticFiles ()
|
|
||||||
let _ = app.UseCookiePolicy ()
|
|
||||||
let _ = app.UseRouting ()
|
|
||||||
let _ = app.UseAuthentication ()
|
|
||||||
let _ = app.UseGiraffeErrorHandler Handlers.Error.error
|
|
||||||
let _ = app.UseEndpoints (fun e -> e.MapGiraffeEndpoints Handlers.routes)
|
|
||||||
app
|
|
||||||
|
|
||||||
/// Compose all the configurations into one
|
|
||||||
let webHost pathSegments =
|
|
||||||
contentRoot
|
|
||||||
>> appConfiguration
|
|
||||||
>> kestrel
|
|
||||||
>> webRoot pathSegments
|
|
||||||
>> logging
|
|
||||||
>> services
|
|
||||||
>> application
|
|
||||||
|
|
||||||
|
|
||||||
[<EntryPoint>]
|
[<EntryPoint>]
|
||||||
let main _ =
|
let main args =
|
||||||
use host = Configure.webHost [| "wwwroot" |] (Directory.GetCurrentDirectory ())
|
//use host = Configure.webHost [| "wwwroot" |] (Directory.GetCurrentDirectory ())
|
||||||
host.Run ()
|
//host.Run ()
|
||||||
|
let builder = WebApplication.CreateBuilder args
|
||||||
|
let _ = builder.Configuration.AddEnvironmentVariables "MPJ_"
|
||||||
|
let svc = builder.Services
|
||||||
|
let cfg = svc.BuildServiceProvider().GetRequiredService<IConfiguration> ()
|
||||||
|
|
||||||
|
let _ = svc.AddRouting ()
|
||||||
|
let _ = svc.AddGiraffe ()
|
||||||
|
let _ = svc.AddSingleton<IClock> SystemClock.Instance
|
||||||
|
let _ = svc.AddSingleton<IDateTimeZoneProvider> DateTimeZoneProviders.Tzdb
|
||||||
|
let _ = svc.Configure<ForwardedHeadersOptions>(fun (opts : ForwardedHeadersOptions) ->
|
||||||
|
opts.ForwardedHeaders <- ForwardedHeaders.XForwardedFor ||| ForwardedHeaders.XForwardedProto)
|
||||||
|
|
||||||
|
let _ =
|
||||||
|
svc.Configure<CookiePolicyOptions>(fun (opts : CookiePolicyOptions) ->
|
||||||
|
opts.MinimumSameSitePolicy <- SameSiteMode.Unspecified
|
||||||
|
opts.OnAppendCookie <- fun ctx -> sameSite ctx.CookieOptions
|
||||||
|
opts.OnDeleteCookie <- fun ctx -> sameSite ctx.CookieOptions)
|
||||||
|
let _ =
|
||||||
|
svc.AddAuthentication(fun opts ->
|
||||||
|
opts.DefaultAuthenticateScheme <- CookieAuthenticationDefaults.AuthenticationScheme
|
||||||
|
opts.DefaultSignInScheme <- CookieAuthenticationDefaults.AuthenticationScheme
|
||||||
|
opts.DefaultChallengeScheme <- CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.AddCookie()
|
||||||
|
.AddOpenIdConnect("Auth0", fun opts ->
|
||||||
|
// Configure OIDC with Auth0 options from configuration
|
||||||
|
let auth0 = cfg.GetSection "Auth0"
|
||||||
|
opts.Authority <- $"""https://{auth0["Domain"]}/"""
|
||||||
|
opts.ClientId <- auth0["Id"]
|
||||||
|
opts.ClientSecret <- auth0["Secret"]
|
||||||
|
opts.ResponseType <- OpenIdConnectResponseType.Code
|
||||||
|
|
||||||
|
opts.Scope.Clear ()
|
||||||
|
opts.Scope.Add "openid"
|
||||||
|
opts.Scope.Add "profile"
|
||||||
|
|
||||||
|
opts.CallbackPath <- PathString "/user/log-on/success"
|
||||||
|
opts.ClaimsIssuer <- "Auth0"
|
||||||
|
opts.SaveTokens <- true
|
||||||
|
|
||||||
|
opts.Events <- OpenIdConnectEvents ()
|
||||||
|
opts.Events.OnRedirectToIdentityProviderForSignOut <- fun ctx ->
|
||||||
|
let returnTo =
|
||||||
|
match ctx.Properties.RedirectUri with
|
||||||
|
| it when isNull it || it = "" -> ""
|
||||||
|
| redirUri ->
|
||||||
|
let finalRedirUri =
|
||||||
|
match redirUri.StartsWith "/" with
|
||||||
|
| true ->
|
||||||
|
// transform to absolute
|
||||||
|
let request = ctx.Request
|
||||||
|
$"{request.Scheme}://{request.Host.Value}{request.PathBase.Value}{redirUri}"
|
||||||
|
| false -> redirUri
|
||||||
|
Uri.EscapeDataString $"&returnTo={finalRedirUri}"
|
||||||
|
ctx.Response.Redirect $"""https://{auth0["Domain"]}/v2/logout?client_id={auth0["Id"]}{returnTo}"""
|
||||||
|
ctx.HandleResponse ()
|
||||||
|
Task.CompletedTask
|
||||||
|
opts.Events.OnRedirectToIdentityProvider <- fun ctx ->
|
||||||
|
let uri = UriBuilder ctx.ProtocolMessage.RedirectUri
|
||||||
|
uri.Scheme <- auth0["Scheme"]
|
||||||
|
uri.Port <- int auth0["Port"]
|
||||||
|
ctx.ProtocolMessage.RedirectUri <- string uri
|
||||||
|
Task.CompletedTask)
|
||||||
|
|
||||||
|
let _ = svc.AddSingleton<JsonSerializerOptions> Json.options
|
||||||
|
let _ = svc.AddSingleton<Json.ISerializer> (SystemTextJson.Serializer Json.options)
|
||||||
|
let _ = Connection.setUp cfg |> Async.AwaitTask |> Async.RunSynchronously
|
||||||
|
|
||||||
|
if builder.Environment.IsDevelopment () then builder.Logging.AddFilter (fun l -> l > LogLevel.Information) |> ignore
|
||||||
|
let _ = builder.Logging.AddConsole().AddDebug() |> ignore
|
||||||
|
|
||||||
|
use app = builder.Build ()
|
||||||
|
let _ = app.UseStaticFiles ()
|
||||||
|
let _ = app.UseCookiePolicy ()
|
||||||
|
let _ = app.UseRouting ()
|
||||||
|
let _ = app.UseAuthentication ()
|
||||||
|
let _ = app.UseGiraffeErrorHandler Handlers.Error.error
|
||||||
|
let _ = app.UseEndpoints (fun e -> e.MapGiraffeEndpoints Handlers.routes)
|
||||||
|
|
||||||
|
app.Run ()
|
||||||
|
|
||||||
0
|
0
|
||||||
@@ -77,9 +77,9 @@ let htmlHead ctx =
|
|||||||
meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ]
|
meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ]
|
||||||
meta [ _name "description"; _content "Online prayer journal - free w/Google or Microsoft account" ]
|
meta [ _name "description"; _content "Online prayer journal - free w/Google or Microsoft account" ]
|
||||||
titleTag ctx
|
titleTag ctx
|
||||||
link [ _href "https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/css/bootstrap.min.css"
|
link [ _href "https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
|
||||||
_rel "stylesheet"
|
_rel "stylesheet"
|
||||||
_integrity "sha384-gH2yIJqKdNHPEq0n4Mqa/HGKIhSkIHeL5AyhkYV8i59U5AR6csBvApHHNl/vI1Bx"
|
_integrity "sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN"
|
||||||
_crossorigin "anonymous" ]
|
_crossorigin "anonymous" ]
|
||||||
link [ _href "https://fonts.googleapis.com/icon?family=Material+Icons"; _rel "stylesheet" ]
|
link [ _href "https://fonts.googleapis.com/icon?family=Material+Icons"; _rel "stylesheet" ]
|
||||||
link [ _href "/style/style.css"; _rel "stylesheet" ]
|
link [ _href "/style/style.css"; _rel "stylesheet" ]
|
||||||
@@ -118,8 +118,8 @@ let htmlFoot =
|
|||||||
rawText "if (!htmx) document.write('<script src=\"/script/htmx.min.js\"><\/script>')"
|
rawText "if (!htmx) document.write('<script src=\"/script/htmx.min.js\"><\/script>')"
|
||||||
]
|
]
|
||||||
script [ _async
|
script [ _async
|
||||||
_src "https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/js/bootstrap.bundle.min.js"
|
_src "https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
|
||||||
_integrity "sha384-A3rJD856KowSb7dwlZdYEkO39Gagi7vIsF0jrRAoQmDKKtQBHUuLZ9AsSv4jD4Xa"
|
_integrity "sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
|
||||||
_crossorigin "anonymous" ] []
|
_crossorigin "anonymous" ] []
|
||||||
script [] [
|
script [] [
|
||||||
rawText "setTimeout(function () { "
|
rawText "setTimeout(function () { "
|
||||||
|
|||||||
@@ -77,28 +77,31 @@ let full (clock : IClock) tz (req : Request) =
|
|||||||
let now = clock.GetCurrentInstant ()
|
let now = clock.GetCurrentInstant ()
|
||||||
let answered =
|
let answered =
|
||||||
req.History
|
req.History
|
||||||
|> Array.filter History.isAnswered
|
|> Seq.ofList
|
||||||
|> Array.tryHead
|
|> Seq.filter History.isAnswered
|
||||||
|
|> Seq.tryHead
|
||||||
|> Option.map (fun x -> x.AsOf)
|
|> Option.map (fun x -> x.AsOf)
|
||||||
let prayed = (req.History |> Array.filter History.isPrayed |> Array.length).ToString "N0"
|
let prayed = (req.History |> List.filter History.isPrayed |> List.length).ToString "N0"
|
||||||
let daysOpen =
|
let daysOpen =
|
||||||
let asOf = defaultArg answered now
|
let asOf = defaultArg answered now
|
||||||
((asOf - (req.History |> Array.filter History.isCreated |> Array.head).AsOf).TotalDays |> int).ToString "N0"
|
((asOf - (req.History |> List.filter History.isCreated |> List.head).AsOf).TotalDays |> int).ToString "N0"
|
||||||
let lastText =
|
let lastText =
|
||||||
req.History
|
req.History
|
||||||
|> Array.filter (fun h -> Option.isSome h.Text)
|
|> Seq.ofList
|
||||||
|> Array.sortByDescending (fun h -> h.AsOf)
|
|> Seq.filter (fun h -> Option.isSome h.Text)
|
||||||
|> Array.map (fun h -> Option.get h.Text)
|
|> Seq.sortByDescending (fun h -> h.AsOf)
|
||||||
|> Array.head
|
|> Seq.map (fun h -> Option.get h.Text)
|
||||||
|
|> Seq.head
|
||||||
// The history log including notes (and excluding the final entry for answered requests)
|
// The history log including notes (and excluding the final entry for answered requests)
|
||||||
let log =
|
let log =
|
||||||
let toDisp (h : History) = {| asOf = h.AsOf; text = h.Text; status = RequestAction.toString h.Status |}
|
let toDisp (h : History) = {| asOf = h.AsOf; text = h.Text; status = RequestAction.toString h.Status |}
|
||||||
let all =
|
let all =
|
||||||
req.Notes
|
req.Notes
|
||||||
|> Array.map (fun n -> {| asOf = n.AsOf; text = Some n.Notes; status = "Notes" |})
|
|> Seq.ofList
|
||||||
|> Array.append (req.History |> Array.map toDisp)
|
|> Seq.map (fun n -> {| asOf = n.AsOf; text = Some n.Notes; status = "Notes" |})
|
||||||
|> Array.sortByDescending (fun it -> it.asOf)
|
|> Seq.append (req.History |> List.map toDisp)
|
||||||
|> List.ofArray
|
|> Seq.sortByDescending (fun it -> it.asOf)
|
||||||
|
|> List.ofSeq
|
||||||
// Skip the first entry for answered requests; that info is already displayed
|
// Skip the first entry for answered requests; that info is already displayed
|
||||||
match answered with Some _ -> all.Tail | None -> all
|
match answered with Some _ -> all.Tail | None -> all
|
||||||
article [ _class "container mt-3" ] [
|
article [ _class "container mt-3" ] [
|
||||||
|
|||||||
@@ -1,12 +1,2 @@
|
|||||||
{
|
{
|
||||||
"ConnectionStrings": {
|
|
||||||
"db": "Filename=./mpj.db"
|
|
||||||
},
|
|
||||||
"Kestrel": {
|
|
||||||
"EndPoints": {
|
|
||||||
"Http": {
|
|
||||||
"Url": "http://localhost:3000"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/*!
|
/*!
|
||||||
* Bootstrap v5.2.0 (https://getbootstrap.com/)
|
* Bootstrap v5.3.2 (https://getbootstrap.com/)
|
||||||
* Copyright 2011-2022 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
|
* Copyright 2011-2023 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
|
||||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||||
*/
|
*/
|
||||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t="transitionend",e=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?i.trim():null}return e},i=t=>{const i=e(t);return i&&document.querySelector(i)?i:null},n=t=>{const i=e(t);return i?document.querySelector(i):null},s=e=>{e.dispatchEvent(new Event(t))},o=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),r=t=>o(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(t):null,a=t=>{if(!o(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},l=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),c=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?c(t.parentNode):null},h=()=>{},d=t=>{t.offsetHeight},u=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,f=[],p=()=>"rtl"===document.documentElement.dir,g=t=>{var e;e=()=>{const e=u();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(f.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of f)t()})),f.push(e)):e()},m=t=>{"function"==typeof t&&t()},_=(e,i,n=!0)=>{if(!n)return void m(e);const o=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(i)+5;let r=!1;const a=({target:n})=>{n===i&&(r=!0,i.removeEventListener(t,a),m(e))};i.addEventListener(t,a),setTimeout((()=>{r||s(i)}),o)},b=(t,e,i,n)=>{const s=t.length;let o=t.indexOf(e);return-1===o?!i&&n?t[s-1]:t[0]:(o+=i?1:-1,n&&(o=(o+s)%s),t[Math.max(0,Math.min(o,s-1))])},v=/[^.]*(?=\..*)\.|.*/,y=/\..*/,w=/::\d+$/,A={};let E=1;const T={mouseenter:"mouseover",mouseleave:"mouseout"},C=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function O(t,e){return e&&`${e}::${E++}`||t.uidEvent||E++}function x(t){const e=O(t);return t.uidEvent=e,A[e]=A[e]||{},A[e]}function k(t,e,i=null){return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===i))}function L(t,e,i){const n="string"==typeof e,s=n?i:e||i;let o=N(t);return C.has(o)||(o=t),[n,s,o]}function D(t,e,i,n,s){if("string"!=typeof e||!t)return;let[o,r,a]=L(e,i,n);if(e in T){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=x(t),c=l[a]||(l[a]={}),h=k(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=O(r,e.replace(v,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return j(s,{delegateTarget:r}),n.oneOff&&P.off(t,s.type,e,i),i.apply(r,[s])}}(t,i,r):function(t,e){return function i(n){return j(n,{delegateTarget:t}),i.oneOff&&P.off(t,n.type,e),e.apply(t,[n])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function S(t,e,i,n,s){const o=k(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function I(t,e,i,n){const s=e[i]||{};for(const o of Object.keys(s))if(o.includes(n)){const n=s[o];S(t,e,i,n.callable,n.delegationSelector)}}function N(t){return t=t.replace(y,""),T[t]||t}const P={on(t,e,i,n){D(t,e,i,n,!1)},one(t,e,i,n){D(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=L(e,i,n),a=r!==e,l=x(t),c=l[r]||{},h=e.startsWith(".");if(void 0===o){if(h)for(const i of Object.keys(l))I(t,l,i,e.slice(1));for(const i of Object.keys(c)){const n=i.replace(w,"");if(!a||e.includes(n)){const
Line truncated
|
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t=new Map,e={set(e,i,n){t.has(e)||t.set(e,new Map);const s=t.get(e);s.has(i)||0===s.size?s.set(i,n):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(s.keys())[0]}.`)},get:(e,i)=>t.has(e)&&t.get(e).get(i)||null,remove(e,i){if(!t.has(e))return;const n=t.get(e);n.delete(i),0===n.size&&t.delete(e)}},i="transitionend",n=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,((t,e)=>`#${CSS.escape(e)}`))),t),s=t=>{t.dispatchEvent(new Event(i))},o=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),r=t=>o(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(n(t)):null,a=t=>{if(!o(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},l=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),c=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?c(t.parentNode):null},h=()=>{},d=t=>{t.offsetHeight},u=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,f=[],p=()=>"rtl"===document.documentElement.dir,m=t=>{var e;e=()=>{const e=u();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(f.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of f)t()})),f.push(e)):e()},g=(t,e=[],i=t)=>"function"==typeof t?t(...e):i,_=(t,e,n=!0)=>{if(!n)return void g(t);const o=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let r=!1;const a=({target:n})=>{n===e&&(r=!0,e.removeEventListener(i,a),g(t))};e.addEventListener(i,a),setTimeout((()=>{r||s(e)}),o)},b=(t,e,i,n)=>{const s=t.length;let o=t.indexOf(e);return-1===o?!i&&n?t[s-1]:t[0]:(o+=i?1:-1,n&&(o=(o+s)%s),t[Math.max(0,Math.min(o,s-1))])},v=/[^.]*(?=\..*)\.|.*/,y=/\..*/,w=/::\d+$/,A={};let E=1;const T={mouseenter:"mouseover",mouseleave:"mouseout"},C=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function O(t,e){return e&&`${e}::${E++}`||t.uidEvent||E++}function x(t){const e=O(t);return t.uidEvent=e,A[e]=A[e]||{},A[e]}function k(t,e,i=null){return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===i))}function L(t,e,i){const n="string"==typeof e,s=n?i:e||i;let o=I(t);return C.has(o)||(o=t),[n,s,o]}function S(t,e,i,n,s){if("string"!=typeof e||!t)return;let[o,r,a]=L(e,i,n);if(e in T){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=x(t),c=l[a]||(l[a]={}),h=k(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=O(r,e.replace(v,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return P(s,{delegateTarget:r}),n.oneOff&&N.off(t,s.type,e,i),i.apply(r,[s])}}(t,i,r):function(t,e){return function i(n){return P(n,{delegateTarget:t}),i.oneOff&&N.off(t,n.type,e),e.apply(t,[n])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function D(t,e,i,n,s){const o=k(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function $(t,e,i,n){const s=e[i]||{};for(const[o,r]of Object.entries(s))o.includes(n)&&D(t,e,i,r.callable,r.delegationSelector)}function I(t){return t=t.replace(y,""),T[t]||t}const N={on(t,e,i,n){S(t,e,i,n,!1)},one(t,e,i,n){S(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=L(e,i,n),a=r!==e,l=x(t),c=l[r]||{},h=e.startsWith(".");if(void 0===o){if(h)for(const i of Object.keys(l))$(t,l,i,e.slLine truncated
|
||||||
//# sourceMappingURL=bootstrap.bundle.min.js.map
|
//# sourceMappingURL=bootstrap.bundle.min.js.map
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
(function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var z={onLoad:t,process:Tt,on:le,off:ue,trigger:ie,ajax:dr,find:b,findAll:f,closest:d,values:function(e,t){var r=Jt(e,t||"post");return r.values},remove:B,addClass:j,removeClass:n,toggleClass:U,takeClass:V,defineExtension:yr,removeExtension:br,logAll:F,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false},parseInterval:v,_:e,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=z.config.wsBinaryType;return t},version:"1.9.2"};var C={addTriggerHandler:xt,bodyContains:ee,canAccessLocalStorage:D,filterValues:er,hasAttribute:q,getAttributeValue:G,getClosestMatch:c,getExpressionVars:fr,getHeaders:Qt,getInputValues:Jt,getInternalData:Y,getSwapSpecification:rr,getTriggerSpecs:ze,getTarget:de,makeFragment:l,mergeObjects:te,makeSettleInfo:S,oobSwap:me,selectAndSwap:Me,settleImmediately:Bt,shouldCancel:Ke,triggerEvent:ie,triggerErrorEvent:ne,withExtensions:w};var R=["get","post","put","delete","patch"];var O=R.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function v(e){if(e==undefined){return undefined}if(e.slice(-2)=="ms"){return parseFloat(e.slice(0,-2))||undefined}if(e.slice(-1)=="s"){return parseFloat(e.slice(0,-1))*1e3||undefined}if(e.slice(-1)=="m"){return parseFloat(e.slice(0,-1))*1e3*60||undefined}return parseFloat(e)||undefined}function $(e,t){return e.getAttribute&&e.getAttribute(t)}function q(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function G(e,t){return $(e,t)||$(e,"data-"+t)}function u(e){return e.parentElement}function J(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function T(e,t,r){var n=G(t,r);var i=G(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function Z(t,r){var n=null;c(t,function(e){return n=T(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function H(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function i(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=J().createDocumentFragment()}return i}function L(e){return e.match(/<body/)}function l(e){var t=!L(e);if(z.config.useTemplateFragments&&t){var r=i("<body><template>"+e+"</template></body>",0);return r.querySelector("template").content}else{var n=H(e);switch(n){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return i("<table>"+e+"</table>",1);case"col":return i("<table><colgroup>"+e+"</colgroup></table>",2);case"tr":return i("<table><tbody>"+e+"</tbody></table>",2);case"td":case"th":return i("<table><tbody><tr>"+e+"</tr></tbody></table>",3);case"script":return i("<div>"+e+"</div>",1);default:return i(e,0)}}}function K(e){if(e){e()}}function A(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function N(e){return A(e,"Function")}function I(e){return A(e,"Object")}function Y(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function k(e){var t=[];if(e){for(var r=0;r<e.length;r++){t.push(e[r])}}return t}function Q(e,t){if(e){for(var r=0;r<e.length;r++){t(e[r])}}}function P(e){var t=e.getBoundingClientRect();var r=t.top;var n=t.bottom;return r<window.innerHeight&&n>=0}function ee(e){if(e.getRootNode&&e.getRootNode()instanceof ShadowRoot){return J().body.contains(e.getRootNode().host)}else{return J().body.contains(e)}}function M(e){return e.trim().split(/\s+/)}function te(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function y(e){try{return JSON.parse(e)}catch(e){x(e);return null}}function D(){var e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function X(t){try{var e=new URL(t);if(e){t=e.pathname+e.search}if(!t.match("^/$")){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return sr(J().body,function(){return eval(e)})}function t(t){var e=z.Line truncated
|
(function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var Y={onLoad:t,process:Pt,on:Z,off:K,trigger:fe,ajax:wr,find:E,findAll:f,closest:v,values:function(e,t){var r=nr(e,t||"post");return r.values},remove:U,addClass:B,removeClass:n,toggleClass:V,takeClass:j,defineExtension:qr,removeExtension:Hr,logAll:X,logNone:F,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get"],selfRequestsOnly:false},parseInterval:d,_:e,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=Y.config.wsBinaryType;return t},version:"1.9.6"};var r={addTriggerHandler:St,bodyContains:oe,canAccessLocalStorage:M,findThisElement:de,filterValues:lr,hasAttribute:o,getAttributeValue:ee,getClosestAttributeValue:re,getClosestMatch:c,getExpressionVars:xr,getHeaders:sr,getInputValues:nr,getInternalData:ie,getSwapSpecification:fr,getTriggerSpecs:Ze,getTarget:ge,makeFragment:l,mergeObjects:se,makeSettleInfo:T,oobSwap:ye,querySelectorExt:le,selectAndSwap:Fe,settleImmediately:Wt,shouldCancel:tt,triggerEvent:fe,triggerErrorEvent:ue,withExtensions:C};var b=["get","post","put","delete","patch"];var w=b.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}if(e.slice(-2)=="ms"){return parseFloat(e.slice(0,-2))||undefined}if(e.slice(-1)=="s"){return parseFloat(e.slice(0,-1))*1e3||undefined}if(e.slice(-1)=="m"){return parseFloat(e.slice(0,-1))*1e3*60||undefined}return parseFloat(e)||undefined}function Q(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function ee(e,t){return Q(e,t)||Q(e,"data-"+t)}function u(e){return e.parentElement}function te(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function O(e,t,r){var n=ee(t,r);var i=ee(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function re(t,r){var n=null;c(t,function(e){return n=O(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function q(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function i(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=te().createDocumentFragment()}return i}function H(e){return e.match(/<body/)}function l(e){var t=!H(e);if(Y.config.useTemplateFragments&&t){var r=i("<body><template>"+e+"</template></body>",0);return r.querySelector("template").content}else{var n=q(e);switch(n){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return i("<table>"+e+"</table>",1);case"col":return i("<table><colgroup>"+e+"</colgroup></table>",2);case"tr":return i("<table><tbody>"+e+"</tbody></table>",2);case"td":case"th":return i("<table><tbody><tr>"+e+"</tr></tbody></table>",3);case"script":case"style":return i("<div>"+e+"</div>",1);default:return i(e,0)}}}function ne(e){if(e){e()}}function L(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function A(e){return L(e,"Function")}function N(e){return L(e,"Object")}function ie(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function I(e){var t=[];if(e){for(var r=0;r<e.length;r++){t.push(e[r])}}return t}function ae(e,t){if(e){for(var r=0;r<e.length;r++){t(e[r])}}}function P(e){var t=e.getBoundingClientRect();var r=t.top;var n=t.bottom;return r<window.innerHeight&&n>=0}function oe(e){if(e.getRootNode&&e.getRootNode()instanceof window.ShadowRoot){return te().body.contains(e.getRootNode().host)}else{return te().body.contains(e)}}function k(e){return e.trim().split(/\s+/)}function se(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function S(e){try{return JSON.parse(e)}catch(e){y(e);return null}}function M(){var e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function D(t){try{var e=new URL(t)Line truncated
|
||||||
+3
-4
@@ -1,7 +1,6 @@
|
|||||||
@charset "UTF-8";/*!
|
@charset "UTF-8";/*!
|
||||||
* Bootstrap v5.2.0 (https://getbootstrap.com/)
|
* Bootstrap v5.3.2 (https://getbootstrap.com/)
|
||||||
* Copyright 2011-2022 The Bootstrap Authors
|
* Copyright 2011-2023 The Bootstrap Authors
|
||||||
* Copyright 2011-2022 Twitter, Inc.
|
|
||||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||||
*/:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-color-rgb:33,37,41;--bs-body-bg-rgb:255,255,255;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-bg:#fff;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-2xl:2rem;--bs-border-radius-pill:50rem;--bs-link-color:#0d6efd;--bs-link-hover-color:#0a58ca;--bs-code-color:#d63384;--bs-highlight-bg:#fff3cd}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;border:0;border-top:1px solid;opacity:.25}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.1875em;background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:var(--bs-link-color);text-decoration:underline}a:hover{color:var(--bs-link-hover-color)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):noLine truncated
|
*/:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherLine truncated
|
||||||
/*# sourceMappingURL=bootstrap.min.css.map */
|
/*# sourceMappingURL=bootstrap.min.css.map */
|
||||||
Reference in new issue
Block a user