import Foundation /// What a seller fills in to list a copy. The listing's slug and placeholder cover are derived from it on publishing. public struct ListingDraft: Sendable, Equatable { // MARK: Properties /// The series or book title, e.g. `Nightward`. public var series: String /// The issue number or volume, e.g. `1`; empty for a one-off book. public var issue: String /// The publisher's name. public var publisher: String /// The cover year. public var year: Int /// The format token, e.g. `single-issues`. public var format: String /// The printing, counted from one; zero for later or unknown. public var printing: Int /// The edition, e.g. `Direct`; empty when not applicable. public var edition: String /// The company that graded and slabbed the copy, or `nil` for a raw copy. public var grader: String? /// The numeric grade, or `nil` for formats that are not graded. public var grade: Double? /// The condition as the seller states it, e.g. `NM` or `Like new`. public var condition: String /// The grader's certificate number, or `nil` for a raw copy. public var certNumber: String? /// The page quality, or `nil` when not stated. public var pageQuality: String? /// Why the issue is a key, or `nil` when it is not one. public var keyNotes: String? /// The defects declared, from the grading guide's checklist. public var defects: [String] /// The seller's notes on the copy. public var sellerNotes: String /// The asking price, in euro cents. public var priceCents: Int /// The shipping cost to the Netherlands, in euro cents. public var shippingCents: Int /// Whether the seller takes offers on the copy. public var acceptsOffers: Bool /// The URL paths of the photos of this copy, the front first. public var photos: [String] // MARK: Initializers public init( series: String, issue: String, publisher: String, year: Int, format: String, printing: Int, edition: String, grader: String?, grade: Double?, condition: String, certNumber: String?, pageQuality: String?, keyNotes: String?, defects: [String], sellerNotes: String, priceCents: Int, shippingCents: Int, acceptsOffers: Bool, photos: [String] ) { self.series = series self.issue = issue self.publisher = publisher self.year = year self.format = format self.printing = printing self.edition = edition self.grader = grader self.grade = grade self.condition = condition self.certNumber = certNumber self.pageQuality = pageQuality self.keyNotes = keyNotes self.defects = defects self.sellerNotes = sellerNotes self.priceCents = priceCents self.shippingCents = shippingCents self.acceptsOffers = acceptsOffers self.photos = photos } } // MARK: - Extensions extension ListingDraft { // MARK: Constants /// The tones a placeholder cover is drawn in. static let tones = ["navy", "wine", "forest", "rust", "slate", "plum", "brick", "olive", "ink"] // MARK: Computed /// The placeholder cover: the series lettered over a few lines, on a tone picked by the series so every copy of it matches. var cover: Listing.Cover { var lines = [String]() for word in series.uppercased().split(separator: " ").map(String.init) { if let last = lines.last, last.count + word.count < 10 { lines[lines.count - 1] = "\(last) \(word)" } else { lines.append(word) } } let marker = switch format { case "manga": issue.isEmpty ? "MANGA" : "VOL \(issue)" case "trade-paperbacks": "TPB" case "hardcovers": "HC" default: issue } return .init( tone: Self.tones[series.unicodeScalars.reduce(0) { ($0 + Int($1.value)) % Self.tones.count }], title: lines.prefix(3).joined(separator: "\n"), imprint: publisher.uppercased(), number: marker ) } /// Whether the copy is a single issue. var isSingleIssue: Bool { format == "single-issues" } /// The printing in a few words, e.g. `1st print`. var printingLabel: String { let noun = isSingleIssue ? "print" : "printing" switch printing { case 1: return "1st \(noun)" case 2: return "2nd \(noun)" case 3: return "3rd \(noun)" default: return "later \(noun)" } } /// The printing in full, e.g. `First printing, direct edition`. var printingDetail: String { let ordinal = switch printing { case 1: "First" case 2: "Second" case 3: "Third" default: "Later" } return edition.isEmpty ? "\(ordinal) printing" : "\(ordinal) printing, \(edition.lowercased()) edition" } /// The listing's title, e.g. `Nightward #1`, or `Hollow Comet, Vol. 4`. var title: String { guard !issue.isEmpty else { return series } return switch format { case "single-issues": "\(series) #\(issue)" case "manga": "\(series), Vol. \(issue)" default: "\(series) \(issue)" } } // MARK: Methods /// The listing the draft publishes as. func listing( slug: String, shop: Shop, at date: Date ) -> Listing { .init( slug: slug, shop: shop, title: title, seriesTitle: isSingleIssue ? "\(series) (\(year) series)" : series, issueLabel: issue.isEmpty ? "—" : (isSingleIssue ? "#\(issue)" : issue), publisher: publisher, year: year, format: format, printing: printingLabel, printingDetail: printingDetail, isFirstPrint: printing == 1, grader: grader, grade: grade, condition: condition, certNumber: certNumber, pageQuality: pageQuality, keyNotes: keyNotes, sellerNotes: sellerNotes, priceCents: priceCents, shippingCents: shippingCents, acceptsOffers: acceptsOffers, cover: cover, listedAt: date, defects: defects, photos: photos ) } /// A string as a URL slug: lowercased, its letters and digits kept, everything else a single hyphen. static func slug( _ string: String ) -> String { string .lowercased() .folding(options: .diacriticInsensitive, locale: nil) .split { !($0.isASCII && ($0.isLetter || $0.isNumber)) } .joined(separator: "-") } }