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

This commit is contained in:
javier committed 2026-09-21 11:16:30 +02:00
1 parent 717df5208c
commit 6d7ab4bcba
19 files changed
+1381 -151

No files matched your search

@@ -0,0 +1,159 @@
import Foundation
/// A `Sendable` snapshot of one copy for sale, with the shop selling it.
public struct Listing: Sendable, Equatable {
// MARK: Properties
/// The listing's URL slug.
public let slug: String
/// The shop selling the copy.
public let shop: Shop
/// The listing's title, e.g. `Nightward #1`.
public let title: String
/// The series the copy belongs to, e.g. `Nightward (2019 series)`.
public let seriesTitle: String
/// The issue or volume, e.g. `#1, cover A`.
public let issueLabel: String
/// The publisher's name.
public let publisher: String
/// The cover year.
public let year: Int
/// The format token, e.g. `single-issues`.
public let format: String
/// The printing in a few words, e.g. `1st print`.
public let printing: String
/// The printing in full, e.g. `First printing, direct edition`.
public let printingDetail: String
/// Whether the copy is a first printing.
public let isFirstPrint: Bool
/// The company that graded and slabbed the copy, or `nil` for a raw copy.
public let grader: String?
/// The numeric grade, or `nil` for formats that are not graded.
public let grade: Double?
/// The condition as the seller states it, e.g. `NM` or `Like new`.
public let condition: String
/// The grader's certificate number, or `nil` for a raw copy.
public let certNumber: String?
/// The page quality, or `nil` when not stated.
public let pageQuality: String?
/// Why the issue is a key, or `nil` when it is not one.
public let keyNotes: String?
/// The seller's notes on the copy.
public let sellerNotes: String
/// The asking price, in euro cents.
public let priceCents: Int
/// The shipping cost to the Netherlands, in euro cents.
public let shippingCents: Int
/// Whether the seller takes offers on the copy.
public let acceptsOffers: Bool
/// The placeholder cover shown until listings carry photos.
public let cover: Cover
/// When the copy was listed.
public let listedAt: Date
// MARK: Initializers
public init(
slug: String,
shop: Shop,
title: String,
seriesTitle: String,
issueLabel: String,
publisher: String,
year: Int,
format: String,
printing: String,
printingDetail: String,
isFirstPrint: Bool,
grader: String?,
grade: Double?,
condition: String,
certNumber: String?,
pageQuality: String?,
keyNotes: String?,
sellerNotes: String,
priceCents: Int,
shippingCents: Int,
acceptsOffers: Bool,
cover: Cover,
listedAt: Date
) {
self.slug = slug
self.shop = shop
self.title = title
self.seriesTitle = seriesTitle
self.issueLabel = issueLabel
self.publisher = publisher
self.year = year
self.format = format
self.printing = printing
self.printingDetail = printingDetail
self.isFirstPrint = isFirstPrint
self.grader = grader
self.grade = grade
self.condition = condition
self.certNumber = certNumber
self.pageQuality = pageQuality
self.keyNotes = keyNotes
self.sellerNotes = sellerNotes
self.priceCents = priceCents
self.shippingCents = shippingCents
self.acceptsOffers = acceptsOffers
self.cover = cover
self.listedAt = listedAt
}
}
// MARK: - Extensions
public extension Listing {
// MARK: Computed
/// Whether the issue is a key.
var isKey: Bool {
keyNotes != nil
}
/// Whether the copy is graded and slabbed by a grading company.
var isSlabbed: Bool {
grader != nil
}
}
// MARK: - Structs
public extension Listing {
/// A placeholder cover: lettering on a toned ground.
struct Cover: Sendable, Equatable {
// MARK: Properties
/// The tone token naming the cover's colours, e.g. `navy`.
public let tone: String
/// The title lettering, one line per newline.
public let title: String
/// The publisher's imprint line.
public let imprint: String
/// The issue number or volume marker.
public let number: String
// MARK: Initializers
public init(
tone: String,
title: String,
imprint: String,
number: String
) {
self.tone = tone
self.title = title
self.imprint = imprint
self.number = number
}
}
}
@@ -0,0 +1,197 @@
/// The criteria a listing search is narrowed and ordered by; the default matches every listing, newest first.
public struct ListingFilter: Sendable, Equatable {
// MARK: Properties
/// Words that must each appear in the listing's title, publisher, or shop name.
public var query: String?
/// The format tokens to match; empty matches all.
public var formats: [String]
/// `true` for slabbed copies only, `false` for raw only, `nil` for both.
public var isSlabbed: Bool?
/// The lowest numeric grade to match.
public var gradeMin: Double?
/// The highest numeric grade to match.
public var gradeMax: Double?
/// Whether to match first printings only.
public var firstPrintOnly: Bool
/// Whether to match key issues only.
public var keysOnly: Bool
/// The publishers to match; empty matches all.
public var publishers: [String]
/// The lowest price to match, in euro cents.
public var priceMin: Int?
/// The highest price to match, in euro cents.
public var priceMax: Int?
/// Where matching listings ship from.
public var shipsFrom: Region
/// Whether to match only listings whose seller takes offers.
public var acceptsOffers: Bool
/// Whether to match only listings from verified shops.
public var verifiedShops: Bool
/// The slug of the one shop to match, or `nil` for all shops.
public var shop: String?
/// The order of the results.
public var sort: Sort
// MARK: Initializers
public init(
query: String? = nil,
formats: [String] = [],
isSlabbed: Bool? = nil,
gradeMin: Double? = nil,
gradeMax: Double? = nil,
firstPrintOnly: Bool = false,
keysOnly: Bool = false,
publishers: [String] = [],
priceMin: Int? = nil,
priceMax: Int? = nil,
shipsFrom: Region = .anywhere,
acceptsOffers: Bool = false,
verifiedShops: Bool = false,
shop: String? = nil,
sort: Sort = .newest
) {
self.query = query
self.formats = formats
self.isSlabbed = isSlabbed
self.gradeMin = gradeMin
self.gradeMax = gradeMax
self.firstPrintOnly = firstPrintOnly
self.keysOnly = keysOnly
self.publishers = publishers
self.priceMin = priceMin
self.priceMax = priceMax
self.shipsFrom = shipsFrom
self.acceptsOffers = acceptsOffers
self.verifiedShops = verifiedShops
self.shop = shop
self.sort = sort
}
}
// MARK: - Enumerations
public extension ListingFilter {
/// Where a listing ships from.
enum Region: String, CaseIterable, Sendable {
/// The Netherlands.
case nl
/// Any member state of the European Union.
case eu
/// Any country.
case anywhere
}
/// The order of a listing search's results.
enum Sort: String, CaseIterable, Sendable {
/// Most recently listed first.
case newest
/// Cheapest first.
case priceAscending = "price-asc"
/// Most expensive first.
case priceDescending = "price-desc"
/// Highest grade first; ungraded listings last.
case gradeDescending = "grade-desc"
}
}
// MARK: - Extensions
extension ListingFilter {
// MARK: Methods
/// Whether the listing meets every criterion.
/// - Parameter listing: the listing to test.
func matches(
_ listing: Listing
) -> Bool {
if let shop, listing.shop.slug != shop {
return false
}
if !formats.isEmpty, !formats.contains(listing.format) {
return false
}
if let isSlabbed, listing.isSlabbed != isSlabbed {
return false
}
if gradeMin != nil || gradeMax != nil {
guard
let grade = listing.grade,
grade >= gradeMin ?? 0,
grade <= gradeMax ?? 10
else {
return false
}
}
if firstPrintOnly, !listing.isFirstPrint {
return false
}
if keysOnly, !listing.isKey {
return false
}
if !publishers.isEmpty, !publishers.contains(listing.publisher) {
return false
}
if let priceMin, listing.priceCents < priceMin {
return false
}
if let priceMax, listing.priceCents > priceMax {
return false
}
if acceptsOffers, !listing.acceptsOffers {
return false
}
if verifiedShops, !listing.shop.isVerified {
return false
}
switch shipsFrom {
case .nl where listing.shop.country != "NL": return false
case .eu where !Self.euCountries.contains(listing.shop.country): return false
default: break
}
let haystack = "\(listing.title) \(listing.publisher) \(listing.shop.name)".lowercased()
return (query ?? "")
.lowercased()
.split(whereSeparator: \.isWhitespace)
.allSatisfy { haystack.contains($0) }
}
/// Whether the first listing sorts ahead of the second.
func ordersBefore(
_ lhs: Listing,
_ rhs: Listing
) -> Bool {
switch sort {
case .newest: lhs.listedAt > rhs.listedAt
case .priceAscending: lhs.priceCents < rhs.priceCents
case .priceDescending: lhs.priceCents > rhs.priceCents
case .gradeDescending: (lhs.grade ?? -1) > (rhs.grade ?? -1)
}
}
// MARK: Constants
/// The ISO 3166-1 alpha-2 codes of the European Union's member states.
static let euCountries: Set<String> = [
"AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR", "GR", "HR", "HU",
"IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL", "PT", "RO", "SE", "SI", "SK",
]
}
@@ -0,0 +1,65 @@
/// A `Sendable` snapshot of a seller's shop.
public struct Shop: Sendable, Equatable {
// MARK: Properties
/// The shop's URL slug.
public let slug: String
/// The shop's name.
public let name: String
/// The city the shop ships from.
public let city: String
/// The ISO 3166-1 alpha-2 code of the country the shop ships from.
public let country: String
/// The year the shop started selling on the site.
public let sinceYear: Int
/// Whether the shop's identity has been verified.
public let isVerified: Bool
/// The shop's average rating, out of five.
public let rating: Double
/// The number of rated sales behind ``rating``.
public let ratedSales: Int
/// The number of items the shop has sold.
public let soldCount: Int
/// The shop's handling time, in business days.
public let handlingDays: Int
/// Where and how the shop ships.
public let shipsNote: String
/// The shop's returns policy, in a few words.
public let returnsNote: String
/// The shop's description of itself.
public let about: String
// MARK: Initializers
public init(
slug: String,
name: String,
city: String,
country: String,
sinceYear: Int,
isVerified: Bool,
rating: Double,
ratedSales: Int,
soldCount: Int,
handlingDays: Int,
shipsNote: String,
returnsNote: String,
about: String
) {
self.slug = slug
self.name = name
self.city = city
self.country = country
self.sinceYear = sinceYear
self.isVerified = isVerified
self.rating = rating
self.ratedSales = ratedSales
self.soldCount = soldCount
self.handlingDays = handlingDays
self.shipsNote = shipsNote
self.returnsNote = returnsNote
self.about = about
}
}