Added the catalogue models and seed to support phase 5 of the POC prototype in the Persistence package.

This commit is contained in:
javier committed 2026-09-21 13:31:44 +02:00
1 parent 9403931109
commit 89a7a7b8e5
19 files changed
+990 -15

No files matched your search

@@ -152,6 +152,24 @@ public extension Listing {
keyNotes != nil
}
/// The issue's number within its series, or `nil` for a copy that is not a numbered single issue.
var issueNumber: Int? {
format == "single-issues"
? Int(issueLabel.drop { !$0.isNumber }.prefix { $0.isNumber })
: nil
}
/// The name of the series the copy belongs to: its series title, less the `(2019 series)` a single issue's carries.
var seriesName: String {
seriesTitle
.replacing(#/\s\(\d{4} series\)$/#, with: "")
}
/// The slug of the series the copy belongs to.
var seriesSlug: String {
ListingDraft.slug(seriesName)
}
/// Whether the copy is graded and slabbed by a grading company.
var isSlabbed: Bool {
grader != nil
@@ -0,0 +1,37 @@
/// A `Sendable` snapshot of a series in the catalogue: a run of issues, which is what lets a gap in it show.
public struct Series: Sendable, Equatable {
// MARK: Properties
/// The series' URL slug: its name, slugged.
public let slug: String
/// The series' name, e.g. `Nightward`.
public let name: String
/// The publisher's name.
public let publisher: String
/// The years the series ran, e.g. `2019–2021`.
public let years: String
/// The number of issues in the run; zero for a series the catalogue only knows from its listings.
public let issueCount: Int
/// The series in a sentence or two.
public let blurb: String
// MARK: Initializers
public init(
slug: String,
name: String,
publisher: String,
years: String,
issueCount: Int,
blurb: String
) {
self.slug = slug
self.name = name
self.publisher = publisher
self.years = years
self.issueCount = issueCount
self.blurb = blurb
}
}
@@ -29,6 +29,14 @@ public struct Shop: Sendable, Equatable {
public let returnsNote: String
/// The shop's description of itself.
public let about: String
/// Whether the seller sells as a private collector or as a business.
public let kind: Kind
/// The business's Chamber of Commerce (KvK) number, shown on its shop page.
public let kvkNumber: String?
/// The business's VAT (BTW) identifier.
public let vatID: String?
/// The business's registered name.
public let legalName: String?
// MARK: Initializers
@@ -45,7 +53,11 @@ public struct Shop: Sendable, Equatable {
handlingDays: Int,
shipsNote: String,
returnsNote: String,
about: String
about: String,
kind: Kind = .business,
kvkNumber: String? = nil,
vatID: String? = nil,
legalName: String? = nil
) {
self.slug = slug
self.name = name
@@ -60,6 +72,35 @@ public struct Shop: Sendable, Equatable {
self.shipsNote = shipsNote
self.returnsNote = returnsNote
self.about = about
self.kind = kind
self.kvkNumber = kvkNumber
self.vatID = vatID
self.legalName = legalName
}
}
// MARK: - Extensions
public extension Shop {
// MARK: Computed
/// Whether the shop ships from a member state of the European Union, so a delivery within it crosses no customs border.
var shipsFromEU: Bool {
ListingFilter.euCountries.contains(country)
}
}
// MARK: - Enumerations
public extension Shop {
/// How a seller sells, which decides what consumer law asks of them.
enum Kind: String, CaseIterable, Sendable {
/// A business: its KvK number shows on its shop page, and consumers get the 14-day right of withdrawal.
case business
/// A private collector selling their own books: no statutory right of withdrawal.
case privateSeller = "private"
}
}
@@ -0,0 +1,57 @@
/// What an account fills in to open a shop.
public struct ShopDraft: Sendable, Equatable {
// MARK: Properties
/// The shop's name.
public var name: String
/// The shop's handle: its URL slug.
public var handle: String
/// The city the shop ships from.
public var city: String
/// The ISO 3166-1 alpha-2 code of the country the shop ships from.
public var country: String
/// The shop's description of itself.
public var about: String
/// Whether the seller sells as a private collector or as a business.
public var kind: Shop.Kind
/// The business's KvK number.
public var kvkNumber: String?
/// The business's VAT identifier.
public var vatID: String?
/// The business's registered name.
public var legalName: String?
/// The shop's returns policy, in a few words.
public var returnsNote: String
/// The shop's handling time, in business days.
public var handlingDays: Int
// MARK: Initializers
public init(
name: String,
handle: String,
city: String,
country: String,
about: String,
kind: Shop.Kind,
kvkNumber: String?,
vatID: String?,
legalName: String?,
returnsNote: String,
handlingDays: Int
) {
self.name = name
self.handle = handle
self.city = city
self.country = country
self.about = about
self.kind = kind
self.kvkNumber = kvkNumber
self.vatID = vatID
self.legalName = legalName
self.returnsNote = returnsNote
self.handlingDays = handlingDays
}
}
@@ -0,0 +1,78 @@
import Foundation
/// A `Sendable` snapshot of a copy an account is looking for, with the listings that meet it.
public struct Want: Sendable, Equatable {
// MARK: Properties
/// The want's identifier.
public let id: UUID
/// What is wanted: words that must each appear in a listing's title, e.g. `Nightward #7`.
public let title: String
/// The lowest grade wanted, or `nil` for any.
public let minGrade: Double?
/// The most the account would pay, in euro cents, or `nil` for any price.
public let maxPriceCents: Int?
/// Whether only slabbed copies are wanted.
public let slabbedOnly: Bool
/// Whether new matches alert the account.
public let alerts: Bool
/// The listings for sale that meet the want, newest first. Wants match within the EU alone.
public let matches: [Listing]
/// The number of matches listed since the account last looked at its wants.
public let newMatchCount: Int
// MARK: Initializers
public init(
id: UUID,
title: String,
minGrade: Double?,
maxPriceCents: Int?,
slabbedOnly: Bool,
alerts: Bool,
matches: [Listing],
newMatchCount: Int
) {
self.id = id
self.title = title
self.minGrade = minGrade
self.maxPriceCents = maxPriceCents
self.slabbedOnly = slabbedOnly
self.alerts = alerts
self.matches = matches
self.newMatchCount = newMatchCount
}
}
// MARK: - Extensions
public extension Want {
// MARK: Computed
/// The filter finding the want's matches: the title's words, within the limits set, shipping from the EU.
var filter: ListingFilter {
Self.filter(title: title, minGrade: minGrade, maxPriceCents: maxPriceCents, slabbedOnly: slabbedOnly)
}
// MARK: Methods
/// The filter finding a want's matches.
static func filter(
title: String,
minGrade: Double?,
maxPriceCents: Int?,
slabbedOnly: Bool
) -> ListingFilter {
.init(
query: title.replacingOccurrences(of: "·", with: " "),
isSlabbed: slabbedOnly ? true : nil,
gradeMin: minGrade,
priceMax: maxPriceCents,
shipsFrom: .eu
)
}
}