WIP on person table, podcast/ep assignment (#17)

This commit is contained in:
2026-08-02 17:33:38 -04:00
parent 05a810dfc6
commit d9e7c771ba
18 changed files with 471 additions and 101 deletions
+9
View File
@@ -108,6 +108,14 @@ module Json =
override _.ReadJson(reader: JsonReader, _: Type, _: PersonGroup, _: bool, _: JsonSerializer) = override _.ReadJson(reader: JsonReader, _: Type, _: PersonGroup, _: bool, _: JsonSerializer) =
(string >> PersonGroup.Parse) reader.Value (string >> PersonGroup.Parse) reader.Value
/// <summary>Converter for the <see cref="PersonId" /> type</summary>
type PersonIdConverter() =
inherit JsonConverter<PersonId>()
override _.WriteJson(writer: JsonWriter, value: PersonId, _: JsonSerializer) =
writer.WriteValue(string value)
override _.ReadJson(reader: JsonReader, _: Type, _: PersonId, _: bool, _: JsonSerializer) =
(string >> PersonId) reader.Value
/// <summary>Converter for the <see cref="PersonRole" /> type</summary> /// <summary>Converter for the <see cref="PersonRole" /> type</summary>
type PersonRoleConverter() = type PersonRoleConverter() =
inherit JsonConverter<PersonRole>() inherit JsonConverter<PersonRole>()
@@ -199,6 +207,7 @@ module Json =
PageIdConverter() PageIdConverter()
PermalinkConverter() PermalinkConverter()
PersonGroupConverter() PersonGroupConverter()
PersonIdConverter()
PersonRoleConverter() PersonRoleConverter()
PodcastMediumConverter() PodcastMediumConverter()
PostIdConverter() PostIdConverter()
+28
View File
@@ -97,6 +97,31 @@ type IPageData =
abstract member UpdatePriorPermalinks : PageId -> WebLogId -> Permalink list -> Task<bool> abstract member UpdatePriorPermalinks : PageId -> WebLogId -> Permalink list -> Task<bool>
/// Data functions to support manipulating persons
type IPersonData =
/// Add a person
abstract member Add : Person -> Task<unit>
/// Delete a person
abstract member Delete : PersonId -> WebLogId -> Task<bool>
/// Find all persons by their given assignments
abstract member FindByAssignment : PersonAssignment list -> Task<Person list>
/// Find a person by their ID
abstract member FindById : PersonId -> WebLogId -> Task<Person option>
/// Find persons for the given web log
abstract member FindByWebLog : WebLogId -> Task<Person list>
/// Restore persons from a backup
abstract member Restore : Person list -> Task<unit>
/// Update a person's information
abstract member Update : Person -> Task<unit>
/// Data functions to support manipulating posts /// Data functions to support manipulating posts
type IPostData = type IPostData =
@@ -310,6 +335,9 @@ type IData =
/// Page data functions /// Page data functions
abstract member Page : IPageData abstract member Page : IPageData
/// Person data functions
abstract member Person : IPersonData
/// Post data functions /// Post data functions
abstract member Post : IPostData abstract member Post : IPostData
+2
View File
@@ -26,6 +26,7 @@
<Compile Include="SQLite\SQLiteHelpers.fs" /> <Compile Include="SQLite\SQLiteHelpers.fs" />
<Compile Include="SQLite\SQLiteCategoryData.fs" /> <Compile Include="SQLite\SQLiteCategoryData.fs" />
<Compile Include="SQLite\SQLitePageData.fs" /> <Compile Include="SQLite\SQLitePageData.fs" />
<Compile Include="SQLite\SQLitePersonData.fs" />
<Compile Include="SQLite\SQLitePostData.fs" /> <Compile Include="SQLite\SQLitePostData.fs" />
<Compile Include="SQLite\SQLiteTagMapData.fs" /> <Compile Include="SQLite\SQLiteTagMapData.fs" />
<Compile Include="SQLite\SQLiteThemeData.fs" /> <Compile Include="SQLite\SQLiteThemeData.fs" />
@@ -37,6 +38,7 @@
<Compile Include="Postgres\PostgresCache.fs" /> <Compile Include="Postgres\PostgresCache.fs" />
<Compile Include="Postgres\PostgresCategoryData.fs" /> <Compile Include="Postgres\PostgresCategoryData.fs" />
<Compile Include="Postgres\PostgresPageData.fs" /> <Compile Include="Postgres\PostgresPageData.fs" />
<Compile Include="Postgres\PostgresPersonData.fs" />
<Compile Include="Postgres\PostgresPostData.fs" /> <Compile Include="Postgres\PostgresPostData.fs" />
<Compile Include="Postgres\PostgresTagMapData.fs" /> <Compile Include="Postgres\PostgresTagMapData.fs" />
<Compile Include="Postgres\PostgresThemeData.fs" /> <Compile Include="Postgres\PostgresThemeData.fs" />
@@ -22,6 +22,10 @@ module Table =
[<Literal>] [<Literal>]
let PageRevision = "page_revision" let PageRevision = "page_revision"
/// Persons
[<Literal>]
let Person = "person"
/// Posts /// Posts
[<Literal>] [<Literal>]
let Post = "post" let Post = "post"
@@ -0,0 +1,78 @@
namespace MyWebLog.Data.Postgres
open BitBadger.Documents
open BitBadger.Documents.Postgres
open Microsoft.Extensions.Logging
open MyWebLog
open MyWebLog.Data
open NodaTime
open Npgsql.FSharp
/// PostgreSQL myWebLog person data implementation
type PostgresPersonData(log: ILogger) =
/// Add a person
let add (person : Person) =
log.LogTrace "Person.add"
insert Table.Person person
/// Find persons for the given assignments
let findByAssignment (assignments: PersonAssignment list) =
log.LogTrace "Person.findByAssignment"
Find.byFieldsOrdered<Person>
Table.Person
All
[ Field.In (nameof Person.Empty.Id) (assignments |> List.map _.PersonId) ]
[ Field.Named (nameof Person.Empty.Name) ]
/// Find a person by their ID for the given web log
let findById (personId: PersonId) (webLogId: WebLogId) =
log.LogTrace "Person.findById"
Find.firstByContains<Person> Table.Person {| Id = personId; WebLogId = webLogId |}
/// Delete a person by their ID for the given web log
let delete personId webLogId = backgroundTask {
log.LogTrace "Person.delete"
match! findById personId webLogId with
| Some _ ->
// TODO: also remove this person from assignments in episodes and podcasts
do! Custom.nonQuery
$"""{Query.delete Table.Person} WHERE {Query.whereById "@id"}"""
[ idParam personId ]
return true
| None -> return false
}
/// Find all persons for the given web log
let findByWebLog webLogId =
log.LogTrace "Person.findByWebLog"
Find.byContainsOrdered<Person> Table.Person (webLogContains webLogId) [ Field.Named (nameof Person.Empty.Name) ]
/// Restore persons from a backup
let restore persons = backgroundTask {
log.LogTrace "Person.restore"
let! _ =
Configuration.dataSource ()
|> Sql.fromDataSource
|> Sql.executeTransactionAsync
[ Query.insert Table.Person, persons |> List.map (fun person -> [ jsonParam "@data" person ]) ]
()
}
/// Update a person
let update (person: Person) = backgroundTask {
log.LogTrace "Person.update"
match! findById person.Id person.WebLogId with
| Some _ -> do! Update.byId Table.Person person.Id person
| None -> ()
}
interface IPersonData with
member _.Add person = add person
member _.Delete personId webLogId = delete personId webLogId
member _.FindByAssignment assignments = findByAssignment assignments
member _.FindById personId webLogId = findById personId webLogId
member _.FindByWebLog webLogId = findByWebLog webLogId
member _.Restore persons = restore persons
member _.Update person = update person
+7
View File
@@ -66,6 +66,12 @@ type PostgresData(log: ILogger<PostgresData>, ser: JsonSerializer) =
revision_text TEXT NOT NULL, revision_text TEXT NOT NULL,
PRIMARY KEY (page_id, as_of))" PRIMARY KEY (page_id, as_of))"
// Person table
if needsTable Table.Person then
Query.Definition.ensureTable Table.Person
Query.Definition.ensureKey Table.Person PostgreSQL
Query.Definition.ensureDocumentIndex Table.Person Optimized
// Post tables // Post tables
if needsTable Table.Post then if needsTable Table.Post then
Query.Definition.ensureTable Table.Post Query.Definition.ensureTable Table.Post
@@ -268,6 +274,7 @@ type PostgresData(log: ILogger<PostgresData>, ser: JsonSerializer) =
member _.Category = PostgresCategoryData log member _.Category = PostgresCategoryData log
member _.Page = PostgresPageData log member _.Page = PostgresPageData log
member _.Person = PostgresPersonData log
member _.Post = PostgresPostData log member _.Post = PostgresPostData log
member _.TagMap = PostgresTagMapData log member _.TagMap = PostgresTagMapData log
member _.Theme = PostgresThemeData log member _.Theme = PostgresThemeData log
+72 -1
View File
@@ -23,6 +23,9 @@ module private RethinkHelpers =
/// The page table /// The page table
let Page = "Page" let Page = "Page"
/// The person table
let Person = "Person"
/// The post table /// The post table
let Post = "Post" let Post = "Post"
@@ -45,7 +48,8 @@ module private RethinkHelpers =
let WebLogUser = "WebLogUser" let WebLogUser = "WebLogUser"
/// A list of all tables /// A list of all tables
let all = [ Category; Comment; DbVersion; Page; Post; TagMap; Theme; ThemeAsset; Upload; WebLog; WebLogUser ] let all =
[ Category; Comment; DbVersion; Page; Person; Post; TagMap; Theme; ThemeAsset; Upload; WebLog; WebLogUser ]
/// Index names for indexes not on a data item's name /// Index names for indexes not on a data item's name
@@ -616,6 +620,72 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
} }
} }
member _.Person = {
new IPersonData with
member _.Add person = rethink {
withTable Table.Person
insert person
write; withRetryDefault; ignoreResult conn
}
member _.Delete personId webLogId = backgroundTask {
// TODO: also remove this person from assignments in episodes and podcasts
let! result = rethink<Model.Result> {
withTable Table.Person
getAll [ personId ]
filter (fun row -> row[nameof Person.Empty.WebLogId].Eq webLogId :> obj)
delete
write; withRetryDefault conn
}
return result.Deleted > 0UL
}
member _.FindByAssignment assignments = rethink {
withTable Table.Person
getAll (assignments |> List.map _.PersonId)
orderBy (nameof Person.Empty.Name)
result; withRetryDefault conn
}
member _.FindById personId webLogId =
rethink<Person list> {
withTable Table.Person
getAll [ personId ]
filter (nameof Person.Empty.WebLogId) webLogId
result; withRetryDefault
}
|> tryFirst <| conn
member _.FindByWebLog webLogId = rethink<Person list> {
withTable Table.Person
getAll [ webLogId ] (nameof Person.Empty.WebLogId)
orderBy (nameof Person.Empty.Name)
result; withRetryDefault conn
}
member _.Restore persons = backgroundTask {
for batch in persons |> List.chunkBySize restoreBatchSize do
do! rethink {
withTable Table.Person
insert batch
write; withRetryOnce; ignoreResult conn
}
}
member this.Update person = backgroundTask {
match! this.FindById person.Id person.WebLogId with
| Some _ ->
do! rethink {
withTable Table.Person
get person.Id
replace person
write; withRetryDefault; ignoreResult conn
}
| None -> ()
}
}
member _.Post = { member _.Post = {
new IPostData with new IPostData with
@@ -1281,6 +1351,7 @@ type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger<Rethi
do! ensureIndexes Table.Category [ nameof Category.Empty.WebLogId ] do! ensureIndexes Table.Category [ nameof Category.Empty.WebLogId ]
do! ensureIndexes Table.Comment [ nameof Comment.Empty.PostId ] do! ensureIndexes Table.Comment [ nameof Comment.Empty.PostId ]
do! ensureIndexes Table.Page [ nameof Page.Empty.WebLogId; nameof Page.Empty.AuthorId ] do! ensureIndexes Table.Page [ nameof Page.Empty.WebLogId; nameof Page.Empty.AuthorId ]
do! ensureIndexes Table.Person [ nameof Person.Empty.WebLogId ]
do! ensureIndexes Table.Post [ nameof Post.Empty.WebLogId; nameof Post.Empty.AuthorId ] do! ensureIndexes Table.Post [ nameof Post.Empty.WebLogId; nameof Post.Empty.AuthorId ]
do! ensureIndexes Table.TagMap [] do! ensureIndexes Table.TagMap []
do! ensureIndexes Table.Upload [] do! ensureIndexes Table.Upload []
@@ -22,6 +22,9 @@ module Table =
[<Literal>] [<Literal>]
let PageRevision = "page_revision" let PageRevision = "page_revision"
[<Literal>]
let Person = "person"
/// Posts /// Posts
[<Literal>] [<Literal>]
let Post = "post" let Post = "post"
@@ -0,0 +1,71 @@
namespace MyWebLog.Data.SQLite
open BitBadger.Documents
open BitBadger.Documents.Sqlite
open Microsoft.Data.Sqlite
open Microsoft.Extensions.Logging
open MyWebLog
open MyWebLog.Data
/// SQLite myWebLog person data implementation
type SQLitePersonData(conn: SqliteConnection, log: ILogger) =
/// Add a person
let add (person: Person) =
log.LogTrace "Person.add"
conn.insert Table.Person person
/// Find persons by their assignments
let findByAssignment (assignments: PersonAssignment list) =
log.LogTrace "Person.findByAssignment"
conn.findByFieldsOrdered<Person>
Table.Person All
[ Field.In "PersonId" (assignments |> List.map _.PersonId) ]
[ Field.Named (nameof Person.Empty.Name) ]
/// Find a person by their ID
let findById (personId: PersonId) webLogId =
log.LogTrace "Person.findById"
conn.findFirstByFields<Person> Table.Person All [ idField personId; webLogField webLogId ]
/// Delete a person by their ID
let delete personId webLogId = backgroundTask {
log.LogTrace "Person.delete"
match! findById personId webLogId with
| Some _ ->
// TODO: also remove this person from assignments in episodes and podcasts
do! conn.customNonQuery
$"{Query.byId (Query.delete Table.Person) (string personId)}"
[ idParam personId ]
return true
| None -> return false
}
/// Find a page by its permalink for the given web log
let findByWebLog webLogId =
log.LogTrace "Person.findByWebLog"
conn.findByFieldsOrdered<Person>
Table.Person All [ webLogField webLogId ] [ Field.Named (nameof Person.Empty.Name) ]
/// Restore persons from a backup
let restore persons = backgroundTask {
log.LogTrace "Person.restore"
for person in persons do do! add person
}
/// Update a page
let update (person: Person) = backgroundTask {
log.LogTrace "Person.update"
match! findById person.Id person.WebLogId with
| Some _ -> do! conn.updateById Table.Person person.Id person
| None -> ()
}
interface IPersonData with
member _.Add person = add person
member _.Delete personId webLogId = delete personId webLogId
member _.FindByAssignment assignments = findByAssignment assignments
member _.FindById personId webLogId = findById personId webLogId
member _.FindByWebLog webLogId = findByWebLog webLogId
member _.Restore pages = restore pages
member _.Update page = update page
+12 -3
View File
@@ -74,6 +74,12 @@ type SQLiteData(conn: SqliteConnection, log: ILogger<SQLiteData>, ser: JsonSeria
revision_text TEXT NOT NULL, revision_text TEXT NOT NULL,
PRIMARY KEY (page_id, as_of))" [] PRIMARY KEY (page_id, as_of))" []
// Person table
if needsTable Table.Person then
log.LogInformation(creatingTable, Table.Person)
do! conn.ensureTable Table.Person
do! conn.ensureFieldIndex Table.Person "web_log" [ nameof Person.Empty.WebLogId ]
// Post tables // Post tables
if needsTable Table.Post then if needsTable Table.Post then
log.LogInformation(creatingTable, Table.Post) log.LogInformation(creatingTable, Table.Post)
@@ -184,7 +190,7 @@ type SQLiteData(conn: SqliteConnection, log: ILogger<SQLiteData>, ser: JsonSeria
Summary = Map.getString "summary" podcastRdr Summary = Map.getString "summary" podcastRdr
DisplayedAuthor = Map.getString "displayed_author" podcastRdr DisplayedAuthor = Map.getString "displayed_author" podcastRdr
Email = Map.getString "email" podcastRdr Email = Map.getString "email" podcastRdr
ImageUrl = Map.getString "image_url" podcastRdr |> Permalink ImageUrl = Map.getString "image_url" podcastRdr
AppleCategory = Map.getString "apple_category" podcastRdr AppleCategory = Map.getString "apple_category" podcastRdr
AppleSubcategory = Map.tryString "apple_subcategory" podcastRdr AppleSubcategory = Map.tryString "apple_subcategory" podcastRdr
Explicit = Map.getString "explicit" podcastRdr |> ExplicitRating.Parse Explicit = Map.getString "explicit" podcastRdr |> ExplicitRating.Parse
@@ -194,7 +200,8 @@ type SQLiteData(conn: SqliteConnection, log: ILogger<SQLiteData>, ser: JsonSeria
FundingUrl = Map.tryString "funding_url" podcastRdr FundingUrl = Map.tryString "funding_url" podcastRdr
FundingText = Map.tryString "funding_text" podcastRdr FundingText = Map.tryString "funding_text" podcastRdr
Medium = Map.tryString "medium" podcastRdr Medium = Map.tryString "medium" podcastRdr
|> Option.map PodcastMedium.Parse } |> Option.map PodcastMedium.Parse
People = None } // TODO: retrieve person / role assignments
} |> List.ofSeq } |> List.ofSeq
podcastRdr.Close() podcastRdr.Close()
podcasts podcasts
@@ -231,7 +238,8 @@ type SQLiteData(conn: SqliteConnection, log: ILogger<SQLiteData>, ser: JsonSeria
SeasonNumber = Map.tryInt "season_number" epRdr SeasonNumber = Map.tryInt "season_number" epRdr
SeasonDescription = Map.tryString "season_description" epRdr SeasonDescription = Map.tryString "season_description" epRdr
EpisodeNumber = Map.tryString "episode_number" epRdr |> Option.map Double.Parse EpisodeNumber = Map.tryString "episode_number" epRdr |> Option.map Double.Parse
EpisodeDescription = Map.tryString "episode_description" epRdr } EpisodeDescription = Map.tryString "episode_description" epRdr
People = None } // TODO: get person / role assignments
} |> List.ofSeq } |> List.ofSeq
epRdr.Close() epRdr.Close()
episodes episodes
@@ -503,6 +511,7 @@ type SQLiteData(conn: SqliteConnection, log: ILogger<SQLiteData>, ser: JsonSeria
member _.Category = SQLiteCategoryData (conn, ser, log) member _.Category = SQLiteCategoryData (conn, ser, log)
member _.Page = SQLitePageData (conn, log) member _.Page = SQLitePageData (conn, log)
member _.Person = SQLitePersonData (conn, log)
member _.Post = SQLitePostData (conn, log) member _.Post = SQLitePostData (conn, log)
member _.TagMap = SQLiteTagMapData (conn, log) member _.TagMap = SQLiteTagMapData (conn, log)
member _.Theme = SQLiteThemeData (conn, log) member _.Theme = SQLiteThemeData (conn, log)
+30
View File
@@ -143,6 +143,36 @@ type Page = {
OpenGraph = None } OpenGraph = None }
/// <summary>A person (associated with podcasting)</summary>
[<CLIMutable; NoComparison; NoEquality>]
type Person = {
/// <summary>The ID of this person</summary>
Id: PersonId
/// <summary>The ID of the web log to which this person belongs</summary>
WebLogId: WebLogId
/// <summary>The person's name</summary>
Name: string
/// <summary>A URL for more information about this person</summary>
/// <remarks>Non-absolute URLs will be treated as a permalink</remarks>
Url: string option
/// <summary>A URL with an image / avatar for this person</summary>
/// <remarks>Non-absolute URLs will be treated as a permalink</remarks>
ImageUrl: string option
} with
/// <summary>An empty person entry</summary>
static member Empty =
{ Id = PersonId.Empty
WebLogId = WebLogId.Empty
Name = ""
Url = None
ImageUrl = None }
/// <summary>A web log post</summary> /// <summary>A web log post</summary>
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type Post = { type Post = {
+116 -1
View File
@@ -402,6 +402,37 @@ type PersonRole =
PersonRole.GroupXref |> List.find (fun it -> fst it = this) |> snd PersonRole.GroupXref |> List.find (fun it -> fst it = this) |> snd
open System
/// <summary>An identifier for a person</summary>
[<Struct>]
type PersonId =
| PersonId of string
/// <summary>An empty person ID</summary>
static member Empty = PersonId ""
/// <summary>Create a new person ID</summary>
/// <returns>A new person ID</returns>
static member Create =
Convert.ToBase64String(Guid.NewGuid().ToByteArray()).Replace('/', '_').Replace('+', '-')[..21] |> PersonId
/// <inheritdoc />
override this.ToString() =
match this with PersonId it -> it
/// <summary>The combination of a person and a role</summary>
[<CLIMutable>]
type PersonAssignment = {
/// <summary>The ID of the person to whom this assignment applies</summary>
PersonId: PersonId
/// <summary>The role to which this person is assigned</summary>
Role: PersonRole
}
open NodaTime.Text open NodaTime.Text
/// <summary>A podcast episode</summary> /// <summary>A podcast episode</summary>
@@ -462,6 +493,9 @@ type Episode = {
/// <summary>A description of the episode</summary> /// <summary>A description of the episode</summary>
EpisodeDescription: string option EpisodeDescription: string option
/// <summary>The people and roles for this episode</summary>
People: PersonAssignment list option
} with } with
/// <summary>An empty episode</summary> /// <summary>An empty episode</summary>
@@ -484,7 +518,8 @@ type Episode = {
SeasonNumber = None SeasonNumber = None
SeasonDescription = None SeasonDescription = None
EpisodeNumber = None EpisodeNumber = None
EpisodeDescription = None } EpisodeDescription = None
People = None }
/// <summary>Format a duration for an episode</summary> /// <summary>Format a duration for an episode</summary>
/// <returns>A duration formatted in hours, minutes, and seconds</returns> /// <returns>A duration formatted in hours, minutes, and seconds</returns>
@@ -529,3 +564,83 @@ type PodcastMedium =
| Newsletter -> "newsletter" | Newsletter -> "newsletter"
| Blog -> "blog" | Blog -> "blog"
/// <summary>Options for a feed that describes a podcast</summary>
[<CLIMutable; NoComparison; NoEquality>]
type PodcastOptions = {
/// <summary>The title of the podcast</summary>
Title: string
/// <summary>A subtitle for the podcast</summary>
Subtitle: string option
/// <summary>The number of items in the podcast feed</summary>
ItemsInFeed: int
/// <summary>A summary of the podcast (iTunes field)</summary>
Summary: string
/// <summary>The display name of the podcast author (iTunes field)</summary>
DisplayedAuthor: string
/// <summary>The e-mail address of the user who registered the podcast at iTunes</summary>
Email: string
/// <summary>The link to the image for the podcast</summary>
/// <remarks>Non-absolute URLs will be treated as a permalink</remarks>
ImageUrl: string
/// <summary>The category from Apple Podcasts (iTunes) under which this podcast is categorized</summary>
AppleCategory: string
/// <summary>
/// A further refinement of the categorization of this podcast (Apple Podcasts/iTunes field / values)
/// </summary>
AppleSubcategory: string option
/// <summary>The explictness rating (iTunes field)</summary>
Explicit: ExplicitRating
/// <summary>The default media type for files in this podcast</summary>
DefaultMediaType: string option
/// <summary>
/// The base URL for relative URL media files for this podcast (optional; defaults to web log base)
/// </summary>
MediaBaseUrl: string option
/// <summary>A GUID for this podcast</summary>
PodcastGuid: Guid option
/// <summary>A URL at which information on supporting the podcast may be found (supports permalinks)</summary>
FundingUrl: string option
/// <summary>The text to be displayed in the funding item within the feed</summary>
FundingText: string option
/// <summary>The medium (what the podcast IS, not what it is ABOUT)</summary>
Medium: PodcastMedium option
/// <summary>The people and roles for the podcast overall</summary>
People: PersonAssignment list option
} with
/// <summary>A default set of podcast options</summary>
static member Empty =
{ Title = ""
Subtitle = None
ItemsInFeed = 0
Summary = ""
DisplayedAuthor = ""
Email = ""
ImageUrl = ""
AppleCategory = ""
AppleSubcategory = None
Explicit = No
DefaultMediaType = None
MediaBaseUrl = None
PodcastGuid = None
FundingUrl = None
FundingText = None
Medium = None
People = None }
+13 -89
View File
@@ -552,19 +552,6 @@ type OpenGraphProperties = {
} }
/// <summary>A permanent link</summary>
[<Struct>]
type Permalink =
| Permalink of string
/// <summary>An empty permalink</summary>
static member Empty = Permalink ""
/// <inheritdoc />
override this.ToString() =
match this with Permalink it -> it
/// <summary>An identifier for a page</summary> /// <summary>An identifier for a page</summary>
[<Struct>] [<Struct>]
type PageId = type PageId =
@@ -583,6 +570,19 @@ type PageId =
match this with PageId it -> it match this with PageId it -> it
/// <summary>A permanent link</summary>
[<Struct>]
type Permalink =
| Permalink of string
/// <summary>An empty permalink</summary>
static member Empty = Permalink ""
/// <inheritdoc />
override this.ToString() =
match this with Permalink it -> it
/// <summary>Statuses for posts</summary> /// <summary>Statuses for posts</summary>
[<Struct>] [<Struct>]
type PostStatus = type PostStatus =
@@ -683,82 +683,6 @@ type CustomFeedSource =
match this with | Category (CategoryId catId) -> $"category:{catId}" | Tag tag -> $"tag:{tag}" match this with | Category (CategoryId catId) -> $"category:{catId}" | Tag tag -> $"tag:{tag}"
/// <summary>Options for a feed that describes a podcast</summary>
[<CLIMutable; NoComparison; NoEquality>]
type PodcastOptions = {
/// <summary>The title of the podcast</summary>
Title: string
/// <summary>A subtitle for the podcast</summary>
Subtitle: string option
/// <summary>The number of items in the podcast feed</summary>
ItemsInFeed: int
/// <summary>A summary of the podcast (iTunes field)</summary>
Summary: string
/// <summary>The display name of the podcast author (iTunes field)</summary>
DisplayedAuthor: string
/// <summary>The e-mail address of the user who registered the podcast at iTunes</summary>
Email: string
/// <summary>The link to the image for the podcast</summary>
ImageUrl: Permalink
/// <summary>The category from Apple Podcasts (iTunes) under which this podcast is categorized</summary>
AppleCategory: string
/// <summary>
/// A further refinement of the categorization of this podcast (Apple Podcasts/iTunes field / values)
/// </summary>
AppleSubcategory: string option
/// <summary>The explictness rating (iTunes field)</summary>
Explicit: ExplicitRating
/// <summary>The default media type for files in this podcast</summary>
DefaultMediaType: string option
/// <summary>
/// The base URL for relative URL media files for this podcast (optional; defaults to web log base)
/// </summary>
MediaBaseUrl: string option
/// <summary>A GUID for this podcast</summary>
PodcastGuid: Guid option
/// <summary>A URL at which information on supporting the podcast may be found (supports permalinks)</summary>
FundingUrl: string option
/// <summary>The text to be displayed in the funding item within the feed</summary>
FundingText: string option
/// <summary>The medium (what the podcast IS, not what it is ABOUT)</summary>
Medium: PodcastMedium option
} with
/// <summary>A default set of podcast options</summary>
static member Empty =
{ Title = ""
Subtitle = None
ItemsInFeed = 0
Summary = ""
DisplayedAuthor = ""
Email = ""
ImageUrl = Permalink.Empty
AppleCategory = ""
AppleSubcategory = None
Explicit = No
DefaultMediaType = None
MediaBaseUrl = None
PodcastGuid = None
FundingUrl = None
FundingText = None
Medium = None }
/// <summary>A custom feed</summary> /// <summary>A custom feed</summary>
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type CustomFeed = { type CustomFeed = {
+4 -2
View File
@@ -684,7 +684,7 @@ type EditCustomFeedModel = {
Summary = this.Summary Summary = this.Summary
DisplayedAuthor = this.DisplayedAuthor DisplayedAuthor = this.DisplayedAuthor
Email = this.Email Email = this.Email
ImageUrl = Permalink this.ImageUrl ImageUrl = this.ImageUrl
AppleCategory = this.AppleCategory AppleCategory = this.AppleCategory
AppleSubcategory = noneIfBlank this.AppleSubcategory AppleSubcategory = noneIfBlank this.AppleSubcategory
Explicit = ExplicitRating.Parse this.Explicit Explicit = ExplicitRating.Parse this.Explicit
@@ -693,7 +693,8 @@ type EditCustomFeedModel = {
PodcastGuid = noneIfBlank this.PodcastGuid |> Option.map Guid.Parse PodcastGuid = noneIfBlank this.PodcastGuid |> Option.map Guid.Parse
FundingUrl = noneIfBlank this.FundingUrl FundingUrl = noneIfBlank this.FundingUrl
FundingText = noneIfBlank this.FundingText FundingText = noneIfBlank this.FundingText
Medium = noneIfBlank this.Medium |> Option.map PodcastMedium.Parse } Medium = noneIfBlank this.Medium |> Option.map PodcastMedium.Parse
People = None } // TODO: add this to UI or handle differently
else else
None } None }
@@ -974,6 +975,7 @@ type EditPostModel() =
| Some it -> Some (double it) | Some it -> Some (double it)
| None -> None | None -> None
EpisodeDescription = noneIfBlank this.EpisodeDescription EpisodeDescription = noneIfBlank this.EpisodeDescription
People = None // TODO: add this to UI or handle differently
} }
else else
None } None }
@@ -178,6 +178,20 @@ let personGroupConverterTests = testList "PersonGroupConverter" [
} }
] ]
/// Unit tests for the PersonIdConverter type
let personIdConverterTests = testList "PersonIdConverter" [
let opts = JsonSerializerSettings()
opts.Converters.Add(PersonIdConverter())
test "succeeds when serializing" {
let after = JsonConvert.SerializeObject(PersonId "person-x", opts)
Expect.equal after "\"person-x\"" "Person ID serialized incorrectly"
}
test "succeeds when deserializing" {
let after = JsonConvert.DeserializeObject<PersonId>("\"masked\"", opts)
Expect.equal after (PersonId "masked") "Person ID deserialized incorrectly"
}
]
/// Unit tests for the PersonRoleConverter type /// Unit tests for the PersonRoleConverter type
let personRoleConverterTests = testList "PersonRoleConverter" [ let personRoleConverterTests = testList "PersonRoleConverter" [
let opts = JsonSerializerSettings() let opts = JsonSerializerSettings()
@@ -322,6 +336,7 @@ let configureTests = test "Json.configure succeeds" {
Expect.hasCountOf ser.Converters 1u (has typeof<PageIdConverter>) "Page ID converter not found" Expect.hasCountOf ser.Converters 1u (has typeof<PageIdConverter>) "Page ID converter not found"
Expect.hasCountOf ser.Converters 1u (has typeof<PermalinkConverter>) "Permalink converter not found" Expect.hasCountOf ser.Converters 1u (has typeof<PermalinkConverter>) "Permalink converter not found"
Expect.hasCountOf ser.Converters 1u (has typeof<PersonGroupConverter>) "PersonGroup converter not found" Expect.hasCountOf ser.Converters 1u (has typeof<PersonGroupConverter>) "PersonGroup converter not found"
Expect.hasCountOf ser.Converters 1u (has typeof<PersonIdConverter>) "Person ID converter not found"
Expect.hasCountOf ser.Converters 1u (has typeof<PersonRoleConverter>) "PersonRole converter not found" Expect.hasCountOf ser.Converters 1u (has typeof<PersonRoleConverter>) "PersonRole converter not found"
Expect.hasCountOf ser.Converters 1u (has typeof<PodcastMediumConverter>) "Podcast medium converter not found" Expect.hasCountOf ser.Converters 1u (has typeof<PodcastMediumConverter>) "Podcast medium converter not found"
Expect.hasCountOf ser.Converters 1u (has typeof<PostIdConverter>) "Post ID converter not found" Expect.hasCountOf ser.Converters 1u (has typeof<PostIdConverter>) "Post ID converter not found"
@@ -351,6 +366,7 @@ let all = testList "Converters" [
pageIdConverterTests pageIdConverterTests
permalinkConverterTests permalinkConverterTests
personGroupConverterTests personGroupConverterTests
personIdConverterTests
personRoleConverterTests personRoleConverterTests
podcastMediumConverterTests podcastMediumConverterTests
postIdConverterTests postIdConverterTests
+1 -1
View File
@@ -112,7 +112,7 @@ let ``FindById succeeds when a web log is found`` (data: IData) = task {
Expect.equal pod.Summary "All things that happen in the domain root" "Podcast summary is incorrect" Expect.equal pod.Summary "All things that happen in the domain root" "Podcast summary is incorrect"
Expect.equal pod.DisplayedAuthor "Podcaster Extraordinaire" "Podcast author is incorrect" Expect.equal pod.DisplayedAuthor "Podcaster Extraordinaire" "Podcast author is incorrect"
Expect.equal pod.Email "podcaster@example.com" "Podcast e-mail is incorrect" Expect.equal pod.Email "podcaster@example.com" "Podcast e-mail is incorrect"
Expect.equal pod.ImageUrl (Permalink "images/cover-art.png") "Podcast image URL is incorrect" Expect.equal pod.ImageUrl "images/cover-art.png" "Podcast image URL is incorrect"
Expect.equal pod.AppleCategory "Fiction" "Podcast Apple category is incorrect" Expect.equal pod.AppleCategory "Fiction" "Podcast Apple category is incorrect"
Expect.equal pod.AppleSubcategory (Some "Drama") "Podcast Apple subcategory is incorrect" Expect.equal pod.AppleSubcategory (Some "Drama") "Podcast Apple subcategory is incorrect"
Expect.equal pod.Explicit No "Podcast explicit rating is incorrect" Expect.equal pod.Explicit No "Podcast explicit rating is incorrect"
+3 -2
View File
@@ -282,7 +282,8 @@ let testFullPost =
SeasonNumber = Some 3 SeasonNumber = Some 3
SeasonDescription = Some "Season Three" SeasonDescription = Some "Season Three"
EpisodeNumber = Some 322. EpisodeNumber = Some 322.
EpisodeDescription = Some "Episode 322" } } EpisodeDescription = Some "Episode 322"
People = None } } // TODO: add some people
/// Unit tests for the EditCommonModel type /// Unit tests for the EditCommonModel type
let editCommonModelTests = testList "EditCommonModel" [ let editCommonModelTests = testList "EditCommonModel" [
@@ -524,7 +525,7 @@ let editCustomFeedModelTests = testList "EditCustomFeedModel" [
Summary = "As little as possible" Summary = "As little as possible"
DisplayedAuthor = "The Tester" DisplayedAuthor = "The Tester"
Email = "thetester@example.com" Email = "thetester@example.com"
ImageUrl = Permalink "upload/my-image.png" ImageUrl = "upload/my-image.png"
AppleCategory = "News" AppleCategory = "News"
Explicit = Clean } Explicit = Clean }
// A GUID with all zeroes, ending in "a" // A GUID with all zeroes, ending in "a"
+2 -2
View File
@@ -253,8 +253,8 @@ let private addPodcast (webLog: WebLog) (rssFeed: SyndicationFeed) (feed: Custom
let feedUrl = webLog.AbsoluteUrl feed.Path let feedUrl = webLog.AbsoluteUrl feed.Path
let imageUrl = let imageUrl =
match podcast.ImageUrl with match podcast.ImageUrl with
| Permalink link when link.StartsWith "http" -> link | link when link.StartsWith "http" -> link
| Permalink _ -> webLog.AbsoluteUrl podcast.ImageUrl | _ -> webLog.AbsoluteUrl (Permalink podcast.ImageUrl)
let xmlDoc = XmlDocument() let xmlDoc = XmlDocument()