Version 2.1 #41

Merged
danieljsummers merged 123 commits from version-2.1 into main 2024-03-27 00:13:28 +00:00
9 changed files with 284 additions and 31 deletions
Showing only changes of commit 12b23eab46 - Show all commits

View File

@ -190,7 +190,16 @@ type Chapter = {
/// A location that applies to a chapter /// A location that applies to a chapter
Location: Location option Location: Location option
} } with
/// An empty chapter
static member Empty =
{ StartTime = Duration.Zero
Title = None
ImageUrl = None
IsHidden = None
EndTime = None
Location = None }
open NodaTime.Text open NodaTime.Text

View File

@ -72,9 +72,10 @@ type DisplayCategory = {
PostCount: int PostCount: int
} }
/// A display version of an episode chapter /// A display version of an episode chapter
type DisplayChapter = { type DisplayChapter = {
/// The start time of the chapter (HH:MM:SS.FF format) /// The start time of the chapter (H:mm:ss.FF format)
StartTime: string StartTime: string
/// The title of the chapter /// The title of the chapter
@ -86,7 +87,7 @@ type DisplayChapter = {
/// Whether this chapter should be displayed in podcast players /// Whether this chapter should be displayed in podcast players
IsHidden: bool IsHidden: bool
/// The end time of the chapter (HH:MM:SS.FF format) /// The end time of the chapter (H:mm:ss.FF format)
EndTime: string EndTime: string
/// The name of a location /// The name of a location
@ -101,15 +102,15 @@ type DisplayChapter = {
/// Create a display chapter from a chapter /// Create a display chapter from a chapter
static member FromChapter (chapter: Chapter) = static member FromChapter (chapter: Chapter) =
let pattern = DurationPattern.CreateWithInvariantCulture("H:mm:ss.ff") let pattern = DurationPattern.CreateWithInvariantCulture "H:mm:ss.FF"
{ StartTime = pattern.Format(chapter.StartTime) { StartTime = pattern.Format chapter.StartTime
Title = defaultArg chapter.Title "" Title = defaultArg chapter.Title ""
ImageUrl = defaultArg chapter.ImageUrl "" ImageUrl = defaultArg chapter.ImageUrl ""
IsHidden = defaultArg chapter.IsHidden false IsHidden = defaultArg chapter.IsHidden false
EndTime = chapter.EndTime |> Option.map pattern.Format |> Option.defaultValue "" EndTime = chapter.EndTime |> Option.map pattern.Format |> Option.defaultValue ""
LocationName = chapter.Location |> Option.map (fun l -> l.Name) |> Option.defaultValue "" LocationName = chapter.Location |> Option.map _.Name |> Option.defaultValue ""
LocationGeo = chapter.Location |> Option.map (fun l -> l.Geo) |> Option.flatten |> Option.defaultValue "" LocationGeo = chapter.Location |> Option.map _.Geo |> Option.flatten |> Option.defaultValue ""
LocationOsm = chapter.Location |> Option.map (fun l -> l.Osm) |> Option.flatten |> Option.defaultValue "" } LocationOsm = chapter.Location |> Option.map _.Osm |> Option.flatten |> Option.defaultValue "" }
/// A display version of a custom feed definition /// A display version of a custom feed definition
@ -360,6 +361,78 @@ type EditCategoryModel = {
this.CategoryId = "new" this.CategoryId = "new"
/// View model to add/edit an episode chapter
type EditChapterModel = {
/// The ID of the post to which the chapter belongs
PostId: string
/// The index in the chapter list (-1 means new)
Index: int
/// The start time of the chapter (H:mm:ss.FF format)
StartTime: string
/// The title of the chapter
Title: string
/// An image to display for this chapter
ImageUrl: string
/// Whether this chapter should be displayed in podcast players
IsHidden: bool
/// The end time of the chapter (HH:MM:SS.FF format)
EndTime: string
/// The name of a location
LocationName: string
/// The geographic coordinates of the location
LocationGeo: string
/// An OpenStreetMap query for this location
LocationOsm: string
} with
/// Create a display chapter from a chapter
static member FromChapter (postId: PostId) idx chapter =
let it = DisplayChapter.FromChapter chapter
{ PostId = string postId
Index = idx
StartTime = it.StartTime
Title = it.Title
ImageUrl = it.ImageUrl
IsHidden = it.IsHidden
EndTime = it.EndTime
LocationName = it.LocationName
LocationGeo = it.LocationGeo
LocationOsm = it.LocationOsm }
/// Create a chapter from the values in this model
member this.ToChapter () =
let parseDuration name value =
let pattern =
match value |> Seq.fold (fun count chr -> if chr = ':' then count + 1 else count) 0 with
| 0 -> "S"
| 1 -> "MM:ss"
| 2 -> "H:mm:ss"
| _ -> invalidArg name "Max time format is H:mm:ss"
|> function
| it -> DurationPattern.CreateWithInvariantCulture $"{it}.FFFFFFFFF"
let result = pattern.Parse value
if result.Success then result.Value else raise result.Exception
let location =
match noneIfBlank this.LocationName with
| None -> None
| Some name -> Some { Name = name; Geo = noneIfBlank this.LocationGeo; Osm = noneIfBlank this.LocationOsm }
{ StartTime = parseDuration (nameof this.StartTime) this.StartTime
Title = noneIfBlank this.Title
ImageUrl = noneIfBlank this.ImageUrl
IsHidden = if this.IsHidden then Some true else None
EndTime = noneIfBlank this.EndTime |> Option.map (parseDuration (nameof this.EndTime))
Location = location }
/// View model to edit a custom RSS feed /// View model to edit a custom RSS feed
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type EditCustomFeedModel = { type EditCustomFeedModel = {
@ -1033,14 +1106,15 @@ type ManageChaptersModel = {
/// The title of the post for which chapters are being edited /// The title of the post for which chapters are being edited
Title: string Title: string
Chapters: Chapter array /// The chapters for the post
Chapters: DisplayChapter array
} with } with
/// Create a model from a post and its episode's chapters /// Create a model from a post and its episode's chapters
static member Create (post: Post) = static member Create (post: Post) =
{ Id = string post.Id { Id = string post.Id
Title = post.Title Title = post.Title
Chapters = Array.ofList post.Episode.Value.Chapters.Value } Chapters = post.Episode.Value.Chapters.Value |> List.map DisplayChapter.FromChapter |> Array.ofList }
/// View model to manage permalinks /// View model to manage permalinks

View File

@ -33,6 +33,39 @@ let addBaseToRelativeUrlsTests = testList "PublicHelpers.addBaseToRelativeUrls"
} }
] ]
/// Unit tests for the DisplayChapter type
let displayChapterTests = testList "DisplayChapter.FromChapter" [
test "succeeds for a minimally-filled chapter" {
let chapter = DisplayChapter.FromChapter { Chapter.Empty with StartTime = Duration.FromSeconds 322L }
Expect.equal chapter.StartTime "0:05:22" "Start time not filled/formatted properly"
Expect.equal chapter.Title "" "Title not filled properly"
Expect.equal chapter.ImageUrl "" "Image URL not filled properly"
Expect.isFalse chapter.IsHidden "Is hidden flag not filled properly"
Expect.equal chapter.EndTime "" "End time not filled properly"
Expect.equal chapter.LocationName "" "Location name not filled properly"
Expect.equal chapter.LocationGeo "" "Location geo URL not filled properly"
Expect.equal chapter.LocationOsm "" "Location OSM query not filled properly"
}
test "succeeds for a fully-filled chapter" {
let chapter =
DisplayChapter.FromChapter
{ StartTime = Duration.FromSeconds 7201.43242
Title = Some "My Test Chapter"
ImageUrl = Some "two-hours-in.jpg"
IsHidden = Some true
EndTime = Some (Duration.FromSeconds 7313.788)
Location = Some { Name = "Over Here"; Geo = Some "geo:23432"; Osm = Some "SF98fFSu-8" } }
Expect.equal chapter.StartTime "2:00:01.43" "Start time not filled/formatted properly"
Expect.equal chapter.Title "My Test Chapter" "Title not filled properly"
Expect.equal chapter.ImageUrl "two-hours-in.jpg" "Image URL not filled properly"
Expect.isTrue chapter.IsHidden "Is hidden flag not filled properly"
Expect.equal chapter.EndTime "2:01:53.78" "End time not filled/formatted properly"
Expect.equal chapter.LocationName "Over Here" "Location name not filled properly"
Expect.equal chapter.LocationGeo "geo:23432" "Location geo URL not filled properly"
Expect.equal chapter.LocationOsm "SF98fFSu-8" "Location OSM query not filled properly"
}
]
/// Unit tests for the DisplayCustomFeed type /// Unit tests for the DisplayCustomFeed type
let displayCustomFeedTests = testList "DisplayCustomFeed.FromFeed" [ let displayCustomFeedTests = testList "DisplayCustomFeed.FromFeed" [
test "succeeds for a feed for an existing category" { test "succeeds for a feed for an existing category" {
@ -1071,6 +1104,29 @@ let editUserModelTests = testList "EditUserModel" [
] ]
] ]
/// Unit tests for the ManageChaptersModel type
let manageChaptersModelTests = testList "ManageChaptersModel.Create" [
test "succeeds" {
let model =
ManageChaptersModel.Create
{ Post.Empty with
Id = PostId "test-post"
Title = "Look at all these chapters"
Episode = Some
{ Episode.Empty with
Chapters = Some
[ { Chapter.Empty with StartTime = Duration.FromSeconds 18L }
{ Chapter.Empty with StartTime = Duration.FromSeconds 36L }
{ Chapter.Empty with StartTime = Duration.FromSeconds 180.7 } ] } }
Expect.equal model.Id "test-post" "ID not filled properly"
Expect.equal model.Title "Look at all these chapters" "Title not filled properly"
Expect.hasLength model.Chapters 3 "There should be three chapters"
Expect.equal model.Chapters[0].StartTime "0:00:18" "First chapter not filled properly"
Expect.equal model.Chapters[1].StartTime "0:00:36" "Second chapter not filled properly"
Expect.equal model.Chapters[2].StartTime "0:03:00.7" "Third chapter not filled properly"
}
]
/// Unit tests for the ManagePermalinksModel type /// Unit tests for the ManagePermalinksModel type
let managePermalinksModelTests = testList "ManagePermalinksModel" [ let managePermalinksModelTests = testList "ManagePermalinksModel" [
test "FromPage succeeds" { test "FromPage succeeds" {
@ -1285,6 +1341,7 @@ let userMessageTests = testList "UserMessage" [
/// All tests in the Domain.ViewModels file /// All tests in the Domain.ViewModels file
let all = testList "ViewModels" [ let all = testList "ViewModels" [
addBaseToRelativeUrlsTests addBaseToRelativeUrlsTests
displayChapterTests
displayCustomFeedTests displayCustomFeedTests
displayPageTests displayPageTests
displayRevisionTests displayRevisionTests
@ -1300,6 +1357,7 @@ let all = testList "ViewModels" [
editRssModelTests editRssModelTests
editTagMapModelTests editTagMapModelTests
editUserModelTests editUserModelTests
manageChaptersModelTests
managePermalinksModelTests managePermalinksModelTests
manageRevisionsModelTests manageRevisionsModelTests
postListItemTests postListItemTests

View File

@ -12,15 +12,19 @@ let postgresOnly = (RethinkDbDataTests.env "PG_ONLY" "0") = "1"
/// Whether any of the data tests are being isolated /// Whether any of the data tests are being isolated
let dbOnly = rethinkOnly || sqliteOnly || postgresOnly let dbOnly = rethinkOnly || sqliteOnly || postgresOnly
/// Whether to only run the unit tests (skip database/integration tests)
let unitOnly = (RethinkDbDataTests.env "UNIT_ONLY" "0") = "1"
let allTests = testList "MyWebLog" [ let allTests = testList "MyWebLog" [
if not dbOnly then testList "Domain" [ SupportTypesTests.all; DataTypesTests.all; ViewModelsTests.all ] if not dbOnly then testList "Domain" [ SupportTypesTests.all; DataTypesTests.all; ViewModelsTests.all ]
testList "Data" [ if not unitOnly then
if not dbOnly then ConvertersTests.all testList "Data" [
if not dbOnly then UtilsTests.all if not dbOnly then ConvertersTests.all
if not dbOnly || (dbOnly && rethinkOnly) then RethinkDbDataTests.all if not dbOnly then UtilsTests.all
if not dbOnly || (dbOnly && sqliteOnly) then SQLiteDataTests.all if not dbOnly || (dbOnly && rethinkOnly) then RethinkDbDataTests.all
if not dbOnly || (dbOnly && postgresOnly) then PostgresDataTests.all if not dbOnly || (dbOnly && sqliteOnly) then SQLiteDataTests.all
] if not dbOnly || (dbOnly && postgresOnly) then PostgresDataTests.all
]
] ]
[<EntryPoint>] [<EntryPoint>]

View File

@ -227,15 +227,15 @@ let register () =
typeof<CustomFeed>; typeof<Episode>; typeof<Episode option>; typeof<MetaItem>; typeof<Page> typeof<CustomFeed>; typeof<Episode>; typeof<Episode option>; typeof<MetaItem>; typeof<Page>
typeof<RedirectRule>; typeof<RssOptions>; typeof<TagMap>; typeof<UploadDestination>; typeof<WebLog> typeof<RedirectRule>; typeof<RssOptions>; typeof<TagMap>; typeof<UploadDestination>; typeof<WebLog>
// View models // View models
typeof<DashboardModel>; typeof<DisplayCategory>; typeof<DisplayChapter> typeof<DashboardModel>; typeof<DisplayCategory>; typeof<DisplayChapter>
typeof<DisplayCustomFeed>; typeof<DisplayPage>; typeof<DisplayRevision> typeof<DisplayCustomFeed>; typeof<DisplayPage>; typeof<DisplayRevision>
typeof<DisplayTheme>; typeof<DisplayUpload>; typeof<DisplayUser> typeof<DisplayTheme>; typeof<DisplayUpload>; typeof<DisplayUser>
typeof<EditCategoryModel>; typeof<EditCustomFeedModel>; typeof<EditMyInfoModel> typeof<EditCategoryModel>; typeof<EditChapterModel>; typeof<EditCustomFeedModel>
typeof<EditPageModel>; typeof<EditPostModel>; typeof<EditRedirectRuleModel> typeof<EditMyInfoModel>; typeof<EditPageModel>; typeof<EditPostModel>
typeof<EditRssModel>; typeof<EditTagMapModel>; typeof<EditUserModel> typeof<EditRedirectRuleModel>; typeof<EditRssModel>; typeof<EditTagMapModel>
typeof<LogOnModel>; typeof<ManageChaptersModel>; typeof<ManagePermalinksModel> typeof<EditUserModel>; typeof<LogOnModel>; typeof<ManageChaptersModel>
typeof<ManageRevisionsModel>; typeof<PostDisplay>; typeof<PostListItem> typeof<ManagePermalinksModel>; typeof<ManageRevisionsModel>; typeof<PostDisplay>
typeof<SettingsModel>; typeof<UserMessage> typeof<PostListItem>; typeof<SettingsModel>; typeof<UserMessage>
// Framework types // Framework types
typeof<AntiforgeryTokenSet>; typeof<DateTime option>; typeof<int option>; typeof<KeyValuePair> typeof<AntiforgeryTokenSet>; typeof<DateTime option>; typeof<int option>; typeof<KeyValuePair>
typeof<MetaItem list>; typeof<string list>; typeof<string option>; typeof<TagMap list> typeof<MetaItem list>; typeof<string list>; typeof<string option>; typeof<TagMap list>

View File

@ -386,6 +386,29 @@ let chapters postId : HttpHandler = requireAccess Author >=> fun next ctx -> tas
| Some _ | None -> return! Error.notFound next ctx | Some _ | None -> return! Error.notFound next ctx
} }
// GET /admin/post/{id}/chapter/{idx}
let editChapter (postId, index) : HttpHandler = requireAccess Author >=> fun next ctx -> task {
match! ctx.Data.Post.FindById (PostId postId) ctx.WebLog.Id with
| Some post
when Option.isSome post.Episode
&& Option.isSome post.Episode.Value.Chapters
&& canEdit post.AuthorId ctx ->
let chapter =
if index = -1 then Some Chapter.Empty
else
let chapters = post.Episode.Value.Chapters.Value
if index < List.length chapters then Some chapters[index] else None
match chapter with
| Some chap ->
return!
hashForPage (if index = -1 then "Add a Chapter" else "Edit Chapter")
|> withAntiCsrf ctx
|> addToHash ViewContext.Model (EditChapterModel.FromChapter post.Id index chap)
|> adminBareView "chapter-edit" next ctx
| None -> return! Error.notFound next ctx
| Some _ | None -> return! Error.notFound next ctx
}
// POST /admin/post/save // POST /admin/post/save
let save : HttpHandler = requireAccess Author >=> fun next ctx -> task { let save : HttpHandler = requireAccess Author >=> fun next ctx -> task {
let! model = ctx.BindFormAsync<EditPostModel>() let! model = ctx.BindFormAsync<EditPostModel>()

View File

@ -129,6 +129,7 @@ let router : HttpHandler = choose [
routef "/%s/permalinks" Post.editPermalinks routef "/%s/permalinks" Post.editPermalinks
routef "/%s/revision/%s/preview" Post.previewRevision routef "/%s/revision/%s/preview" Post.previewRevision
routef "/%s/revisions" Post.editRevisions routef "/%s/revisions" Post.editRevisions
routef "/%s/chapter/%i" Post.editChapter
routef "/%s/chapters" Post.chapters routef "/%s/chapters" Post.chapters
]) ])
subRoute "/settings" (requireAccess WebLogAdmin >=> choose [ subRoute "/settings" (requireAccess WebLogAdmin >=> choose [

View File

@ -0,0 +1,84 @@
<h2 class=my-3>{% if model.index < 0 %}Add{% else %}Edit{% endif %} Chapter</h2>
<form method=post hx-target=#chapter_list class=container>
<input type=hidden name=PostId value="{{ model.post_id }}">
<input type=hidden name=Index value={{ model.index }}>
<div class=row>
<div class="col">
<div class=form-floating>
<input type=text id=start_time name=StartTime class=form-control value="{{ model.start_time }}">
<label for=start_time>Start Time</label>
</div>
</div>
<div class="col">
<div class=form-floating>
<input type=text id=title name=Title class=form-control value="{{ model.title }}" placeholder=Title>
<label for=title>Chapter Title</label>
</div>
</div>
</div>
<div class=row>
<div class="col">
<div class=form-floating>
<input type=text id=image_url name=ImageUrl class=form-control value="{{ model.image_url }}"
placeholder="Image URL">
<label for=image_url>Image URL</label>
</div>
</div>
<div class="col">
<div class="form-check form-switch">
<input type=checkbox id=is_hidden name=IsHidden class=form-check-input value=true
{%- if model.is_hidden %} checked{% endif %}>
<label for=is_hidden>Hidden Chapter</label>
</div>
</div>
<div class="col">
<div class=form-floating>
<input type=text id=end_time name=EndTime class=form-control value="{{ model.end_time }}"
placeholder="End Time">
<label for=end_time>End Time</label>
</div>
</div>
</div>
<div class=row>
<div class="col">
<div class="form-check form-switch">
<input type=checkbox id=has_location class=form-check-input value=true
{%- if model.location_name != "" %} checked{% endif %}>
<label for=has_location>Associate Location</label>
</div>
</div>
<div class="col">
<div class=form-floating>
<input type=text id=location_name name=LocationName class=form-control value="{{ model.location_name }}"
placeholder="Location Name">
<label for=location_name>Name</label>
</div>
</div>
<div class="col">
<div class=form-floating>
<input type=text id=location_geo name=LocationGeo class=form-control value="{{ model.location_geo }}"
placeholder="Location Geo URL">
<label for=location_geo>Geo URL</label>
</div>
</div>
<div class="col">
<div class=form-floating>
<input type=text id=location_osm name=LocationOsm class=form-control value="{{ model.location_osm }}"
placeholder="Location OSM Query">
<label for=location_osm>OSM Query</label>
</div>
</div>
</div>
<div class=row>
<div class=col>
{% if model.index < 0 -%}
<button type=submit class="btn btn-primary">Add + New Chapter</button>
<button type=button class="btn btn-secondary">Save</button>
{% else -%}
<button type=submit class="btn btn-primary">Save</button>
{% endif %}
{% assign cancel_link = "admin/post/" | append: model.post_id | append: "/chapters" | relative_link %}
<a href="{{ cancel_link }}" hx-get="{{ cancel_link }}" class="btn btn-secondary" hx-target=body>Cancel</a>
</div>
</div>
</form>

View File

@ -1,6 +1,6 @@
<h2 class=my-3>{{ page_title }}</h2> <h2 class=my-3>{{ page_title }}</h2>
<article> <article>
<form method=post hx-target=body> <form method=post id=chapter_list hx-target=body>
<input type=hidden name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}"> <input type=hidden name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<input type=hidden name=Id value="{{ model.id }}"> <input type=hidden name=Id value="{{ model.id }}">
<div class="container mb-3"> <div class="container mb-3">
@ -29,9 +29,9 @@
<div class=col>{% if chapter.location %}Y{% else %}N{% endif %}</div> <div class=col>{% if chapter.location %}Y{% else %}N{% endif %}</div>
</div> </div>
{% endfor %} {% endfor %}
<div class="row pb-3 mwl-table-detail" id=chapter_new> <div class="row pb-3" id=chapter_new>
{% assign new_link = "admin/post/" | append: model.id | append: "/chapter/new" | relative_link %} {% assign new_link = "admin/post/" | append: model.id | append: "/chapter/-1" | relative_link %}
<a href="{{ new_link }}" hx-get="{{ new_link }}" hx-target=#chapter_new>Add a New Chapter</a> <a class="btn btn-primary" href="{{ new_link }}" hx-get="{{ new_link }}" hx-target=#chapter_new>Add a New Chapter</a>
</div> </div>
</div> </div>
</form> </form>