diff --git a/src/MyWebLog.Domain/SupportTypes.fs b/src/MyWebLog.Domain/SupportTypes.fs index 34179fc..acbb7d9 100644 --- a/src/MyWebLog.Domain/SupportTypes.fs +++ b/src/MyWebLog.Domain/SupportTypes.fs @@ -190,7 +190,16 @@ type Chapter = { /// A location that applies to a chapter 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 diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index 8352ce9..1a9ff82 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -72,9 +72,10 @@ type DisplayCategory = { PostCount: int } + /// A display version of an episode chapter 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 /// The title of the chapter @@ -86,7 +87,7 @@ type DisplayChapter = { /// Whether this chapter should be displayed in podcast players 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 /// The name of a location @@ -101,15 +102,15 @@ type DisplayChapter = { /// Create a display chapter from a chapter static member FromChapter (chapter: Chapter) = - let pattern = DurationPattern.CreateWithInvariantCulture("H:mm:ss.ff") - { StartTime = pattern.Format(chapter.StartTime) + let pattern = DurationPattern.CreateWithInvariantCulture "H:mm:ss.FF" + { StartTime = pattern.Format chapter.StartTime Title = defaultArg chapter.Title "" ImageUrl = defaultArg chapter.ImageUrl "" IsHidden = defaultArg chapter.IsHidden false - EndTime = chapter.EndTime |> Option.map pattern.Format |> Option.defaultValue "" - LocationName = chapter.Location |> Option.map (fun l -> l.Name) |> Option.defaultValue "" - LocationGeo = chapter.Location |> Option.map (fun l -> l.Geo) |> Option.flatten |> Option.defaultValue "" - LocationOsm = chapter.Location |> Option.map (fun l -> l.Osm) |> Option.flatten |> Option.defaultValue "" } + EndTime = chapter.EndTime |> Option.map pattern.Format |> Option.defaultValue "" + LocationName = chapter.Location |> Option.map _.Name |> Option.defaultValue "" + LocationGeo = chapter.Location |> Option.map _.Geo |> Option.flatten |> Option.defaultValue "" + LocationOsm = chapter.Location |> Option.map _.Osm |> Option.flatten |> Option.defaultValue "" } /// A display version of a custom feed definition @@ -360,6 +361,78 @@ type EditCategoryModel = { 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 [] type EditCustomFeedModel = { @@ -1033,14 +1106,15 @@ type ManageChaptersModel = { /// The title of the post for which chapters are being edited Title: string - Chapters: Chapter array + /// The chapters for the post + Chapters: DisplayChapter array } with /// Create a model from a post and its episode's chapters static member Create (post: Post) = { Id = string post.Id 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 diff --git a/src/MyWebLog.Tests/Domain/ViewModelsTests.fs b/src/MyWebLog.Tests/Domain/ViewModelsTests.fs index 969cf90..0915383 100644 --- a/src/MyWebLog.Tests/Domain/ViewModelsTests.fs +++ b/src/MyWebLog.Tests/Domain/ViewModelsTests.fs @@ -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 let displayCustomFeedTests = testList "DisplayCustomFeed.FromFeed" [ 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 let managePermalinksModelTests = testList "ManagePermalinksModel" [ test "FromPage succeeds" { @@ -1285,6 +1341,7 @@ let userMessageTests = testList "UserMessage" [ /// All tests in the Domain.ViewModels file let all = testList "ViewModels" [ addBaseToRelativeUrlsTests + displayChapterTests displayCustomFeedTests displayPageTests displayRevisionTests @@ -1300,6 +1357,7 @@ let all = testList "ViewModels" [ editRssModelTests editTagMapModelTests editUserModelTests + manageChaptersModelTests managePermalinksModelTests manageRevisionsModelTests postListItemTests diff --git a/src/MyWebLog.Tests/Program.fs b/src/MyWebLog.Tests/Program.fs index ab848c5..b2ed6a9 100644 --- a/src/MyWebLog.Tests/Program.fs +++ b/src/MyWebLog.Tests/Program.fs @@ -12,15 +12,19 @@ let postgresOnly = (RethinkDbDataTests.env "PG_ONLY" "0") = "1" /// Whether any of the data tests are being isolated 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" [ if not dbOnly then testList "Domain" [ SupportTypesTests.all; DataTypesTests.all; ViewModelsTests.all ] - testList "Data" [ - if not dbOnly then ConvertersTests.all - if not dbOnly then UtilsTests.all - if not dbOnly || (dbOnly && rethinkOnly) then RethinkDbDataTests.all - if not dbOnly || (dbOnly && sqliteOnly) then SQLiteDataTests.all - if not dbOnly || (dbOnly && postgresOnly) then PostgresDataTests.all - ] + if not unitOnly then + testList "Data" [ + if not dbOnly then ConvertersTests.all + if not dbOnly then UtilsTests.all + if not dbOnly || (dbOnly && rethinkOnly) then RethinkDbDataTests.all + if not dbOnly || (dbOnly && sqliteOnly) then SQLiteDataTests.all + if not dbOnly || (dbOnly && postgresOnly) then PostgresDataTests.all + ] ] [] diff --git a/src/MyWebLog/DotLiquidBespoke.fs b/src/MyWebLog/DotLiquidBespoke.fs index 9ef5b45..eac2c53 100644 --- a/src/MyWebLog/DotLiquidBespoke.fs +++ b/src/MyWebLog/DotLiquidBespoke.fs @@ -227,15 +227,15 @@ let register () = typeof; typeof; typeof; typeof; typeof typeof; typeof; typeof; typeof; typeof // View models - typeof; typeof; typeof - typeof; typeof; typeof - typeof; typeof; typeof - typeof; typeof; typeof - typeof; typeof; typeof - typeof; typeof; typeof - typeof; typeof; typeof - typeof; typeof; typeof - typeof; typeof + typeof; typeof; typeof + typeof; typeof; typeof + typeof; typeof; typeof + typeof; typeof; typeof + typeof; typeof; typeof + typeof; typeof; typeof + typeof; typeof; typeof + typeof; typeof; typeof + typeof; typeof; typeof // Framework types typeof; typeof; typeof; typeof typeof; typeof; typeof; typeof diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index ca68ebb..0b1c670 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -386,6 +386,29 @@ let chapters postId : HttpHandler = requireAccess Author >=> fun next ctx -> tas | 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 let save : HttpHandler = requireAccess Author >=> fun next ctx -> task { let! model = ctx.BindFormAsync() diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index 7028f04..5262fc2 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -129,6 +129,7 @@ let router : HttpHandler = choose [ routef "/%s/permalinks" Post.editPermalinks routef "/%s/revision/%s/preview" Post.previewRevision routef "/%s/revisions" Post.editRevisions + routef "/%s/chapter/%i" Post.editChapter routef "/%s/chapters" Post.chapters ]) subRoute "/settings" (requireAccess WebLogAdmin >=> choose [ diff --git a/src/admin-theme/chapter-edit.liquid b/src/admin-theme/chapter-edit.liquid new file mode 100644 index 0000000..172236f --- /dev/null +++ b/src/admin-theme/chapter-edit.liquid @@ -0,0 +1,84 @@ +

{% if model.index < 0 %}Add{% else %}Edit{% endif %} Chapter

+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ {% if model.index < 0 -%} + + + {% else -%} + + {% endif %} + {% assign cancel_link = "admin/post/" | append: model.post_id | append: "/chapters" | relative_link %} + Cancel +
+
+
diff --git a/src/admin-theme/chapters.liquid b/src/admin-theme/chapters.liquid index fa8a38a..a61ab41 100644 --- a/src/admin-theme/chapters.liquid +++ b/src/admin-theme/chapters.liquid @@ -1,6 +1,6 @@

{{ page_title }}

-
+
@@ -29,9 +29,9 @@
{% if chapter.location %}Y{% else %}N{% endif %}
{% endfor %} -
- {% assign new_link = "admin/post/" | append: model.id | append: "/chapter/new" | relative_link %} - Add a New Chapter +
+ {% assign new_link = "admin/post/" | append: model.id | append: "/chapter/-1" | relative_link %} + Add a New Chapter