A full example of adding String
and AttributedString
output to our custom types, as well as adding the ability to parse String
values into your custom type.
Another questionable project by Ampersand Softworks
A full example of adding String
and AttributedString
output to our custom types, as well as adding the ability to parse String
values into your custom type.
Another questionable project by Ampersand Softworks
import Foundation | |
/// Represents a 13 digit International Standard Book Number. | |
public struct ISBN: Codable, Sendable, Equatable, Hashable { | |
public let prefix: String | |
public let registrationGroup: String | |
public let registrant: String | |
public let publication: String | |
public let checkDigit: String | |
/// Initializes a new ISBN struct | |
/// - Parameters: | |
/// - prefix: The prefix to the registration group | |
/// - registrationGroup: The registration group (as numbers) | |
/// - registrant: The registrant (as number) | |
/// - publication: The publication (as numbers) | |
/// - checkDigit: The check digit used in validation | |
public init( | |
prefix: String, | |
registrationGroup: String, | |
registrant: String, | |
publication: String, | |
checkDigit: String | |
) { | |
self.prefix = prefix | |
self.registrationGroup = registrationGroup | |
self.registrant = registrant | |
self.publication = publication | |
self.checkDigit = checkDigit | |
} | |
} |
import Foundation | |
public extension ISBN { | |
struct FormatStyle: Codable, Equatable, Hashable { | |
/// Defines which ISBN standard to output | |
public enum Standard: Codable, Equatable, Hashable { | |
/// ISBN-13 | |
case isbn13 | |
/// ISBN-10 | |
case isbn10 | |
} | |
public enum Separator: String, Codable, Equatable, Hashable { | |
case hyphen = "-" | |
case space = " " | |
case none = "" | |
} | |
let standard: Standard | |
let separator: Separator | |
/// Initialize an ISBN FormatStyle with the given Standard | |
/// - Parameter standard: Standard, defaults to .isbn13(.hyphen) | |
public init(_ standard: Standard = .isbn13, separator: Separator = .hyphen) { | |
self.standard = standard | |
self.separator = separator | |
} | |
// MARK: Customization Method Chaining | |
public func standard(_ standard: Standard) -> Self { | |
.init(standard, separator: separator) | |
} | |
/// Returns a new instance of `self` with the standard property set. | |
/// - Parameter standard: The standard to use on the final output | |
/// - Returns: A copy of `self` with the standard set | |
public func separator(_ separator: Separator) -> Self { | |
.init(standard, separator: separator) | |
} | |
} | |
} | |
extension ISBN.FormatStyle: Foundation.FormatStyle { | |
/// Returns a textual representation of the `ISBN` value passed in. | |
/// - Parameter value: A `ISBN` value | |
/// - Returns: The textual representation of the value, using the style's `standard`. | |
public func format(_ value: ISBN) -> String { | |
let parts = [ | |
value.prefix, | |
value.registrationGroup, | |
value.registrant, | |
value.publication, | |
value.checkDigit, | |
] | |
switch standard { | |
case .isbn13: | |
return parts.joined(separator: separator.rawValue) | |
case .isbn10: | |
// ISBN-10 is missing the "prefix" portion of the number. | |
return parts.dropFirst().joined(separator: separator.rawValue) | |
} | |
} | |
} | |
// MARK: Convenience methods to access the formatted value | |
public extension ISBN { | |
/// Converts `self` to its textual representation. | |
/// - Returns: String | |
func formatted() -> String { | |
Self.FormatStyle().format(self) | |
} | |
/// Converts `self` to another representation. | |
/// - Parameter style: The format for formatting `self` | |
/// - Returns: A representations of `self` using the given `style`. The type of the return is determined by the FormatStyle.FormatOutput | |
func formatted<F: Foundation.FormatStyle>(_ style: F) -> F.FormatOutput where F.FormatInput == ISBN { | |
style.format(self) | |
} | |
} | |
// MARK: Convenience FormatStyle extensions to ease access | |
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) | |
public extension FormatStyle where Self == ISBN.FormatStyle { | |
static var isbn13: Self { .init(.isbn13, separator: .hyphen) } | |
static var isbn10: Self { .init(.isbn10, separator: .hyphen) } | |
static func isbn( | |
standard: ISBN.FormatStyle.Standard = .isbn13, | |
separator: ISBN.FormatStyle.Separator = .hyphen | |
) -> Self { | |
.init(standard, separator: separator) | |
} | |
} | |
// MARK: - Debug Methods on ISBN | |
extension ISBN: CustomDebugStringConvertible { | |
public var debugDescription: String { | |
"ISBN: \(formatted())" | |
} | |
} |
import Foundation | |
// We need to create a new AttributedScope to contain our new attributes. | |
public extension AttributeScopes { | |
/// Represents the parts of an ISBN which we will be adding attributes to. | |
enum ISBNPart: Hashable { | |
case prefix | |
case registrationGroup | |
case registrant | |
case publication | |
case checkDigit | |
case separator | |
} | |
// Define our new AttributeScope | |
struct ISBNAttributes: AttributeScope { | |
// Our property value to access it. | |
public let isbnPart: ISBNAttributeKey | |
} | |
// We follow the AttributeStringKey protocol to define our new attribute. | |
enum ISBNAttributeKey: AttributedStringKey { | |
public typealias Value = ISBNPart | |
public static let name = "isbnPart" | |
} | |
// This extends AttributeScope to allow us to access our new ISBNPart type quickly. | |
var isbnPart: ISBNPart.Type { ISBNPart.self } | |
} | |
// We extend AttributeDynamicLookup to know about our custom type. | |
public extension AttributeDynamicLookup { | |
subscript<T: AttributedStringKey>(dynamicMember keyPath: KeyPath<AttributeScopes.ISBNAttributes, T>) -> T { | |
self[T.self] | |
} | |
} | |
// MARK: - AttributedString output FormatStyle | |
public extension ISBN { | |
/// An ISBN FormatStyle for outputting AttributedString values. | |
struct AttributedStringFormatStyle: Codable, Foundation.FormatStyle { | |
private let standard: ISBN.FormatStyle.Standard | |
private let separator: ISBN.FormatStyle.Separator | |
/// Initialize an ISBN FormatStyle with the given Standard | |
/// - Parameter standard: Standard (required) | |
public init(standard: ISBN.FormatStyle.Standard, separator: ISBN.FormatStyle.Separator) { | |
self.standard = standard | |
self.separator = separator | |
} | |
// The format method required by the FormatStyle protocol. | |
public func format(_ value: ISBN) -> AttributedString { | |
// Creates AttributedString representations of each part of the ISBN | |
var prefix = AttributedString(value.prefix) | |
var group = AttributedString(value.registrationGroup) | |
var registrant = AttributedString(value.registrant) | |
var publication = AttributedString(value.publication) | |
var checkDigit = AttributedString(value.checkDigit) | |
// Assigns our custom attribute scope attribute to each part. | |
prefix.isbnPart = .prefix | |
group.isbnPart = .registrationGroup | |
registrant.isbnPart = .registrant | |
publication.isbnPart = .publication | |
checkDigit.isbnPart = .checkDigit | |
// Collect all parts in an array to allow for simple AttributedString concatenation using reduce | |
let parts = [ | |
prefix, | |
group, | |
registrant, | |
publication, | |
checkDigit, | |
] | |
// Create the final AttributedString by using the reduce method. We define the | |
switch standard { | |
case .isbn13 where separator == .none: | |
// Merge all parts into one string. | |
return parts.reduce(AttributedString(), +) | |
case .isbn13: | |
// Define the delimiter | |
var separator = AttributedString(separator.rawValue) | |
separator.isbnPart = .separator | |
// Starting with the .prefix, use reduce to build the final AttributedString. | |
return parts.dropFirst().reduce(prefix) { $0 + separator + $1 } | |
case .isbn10 where separator == .none: | |
// Drop the prefix, merge all parts. | |
return parts.dropFirst().reduce(group, +) | |
case .isbn10: | |
// Define the delimiter | |
var separator = AttributedString(separator.rawValue) | |
separator.isbnPart = .separator | |
// Drop the first two elements (prefix and group), then build the final AttributedString | |
return parts.dropFirst(2).reduce(group) { $0 + separator + $1 } | |
} | |
} | |
} | |
} | |
// MARK: AttributedStringFormatStyle convenience accessors | |
// Add our new attributed method chain to our format style. | |
public extension ISBN.FormatStyle { | |
var attributed: ISBN.AttributedStringFormatStyle { | |
.init(standard: standard, separator: separator) | |
} | |
} |
import Foundation | |
// MARK: Add Validation to ISBN | |
public extension ISBN { | |
// Define our validation errors | |
enum ValidationError: Error { | |
case emptyInput | |
case noGroupsPresent | |
case invalidStringLength | |
case invalidCharacters | |
case checksumFailed | |
} | |
// Define our valid character set. We avoid using CharacterSet.decimalDigit since that includes | |
// all unicode characters which represents digits. ISBN values only use the Arabic numerals, | |
// hyphens, or spaces. | |
static let validCharacterSet = CharacterSet(charactersIn: "0123456789").union(validSeparatorsSet) | |
// Define our valid separators. | |
static let validSeparatorsSet = CharacterSet(charactersIn: "- ") | |
// Define the "Bookland" prefix (https://en.wikipedia.org/wiki/Bookland) to convert ISBN-10 values to ISBN-13 | |
static let booklandPrefix = "978" | |
/// Returns a validated, 13 digit ISBN string. | |
/// https://en.wikipedia.org/wiki/ISBN#ISBN-13_check_digit_calculation | |
/// - Parameter value: A string representation of an ISBN | |
/// - Returns: String, the valid String that passed the check. | |
static func validate(_ candidate: String?) throws -> String { | |
// Unwrap the value passed in. | |
guard let candidate = candidate else { throw ValidationError.emptyInput } | |
// Validate that we have spacers present, otherwise we're not going to be able to parse out | |
// any ISBN values | |
guard candidate.rangeOfCharacter(from: Self.validSeparatorsSet) != nil else { | |
throw ValidationError.noGroupsPresent | |
} | |
// Trim any leading and trailing whitespace and newlines. | |
// Newlines will fail on the next check. | |
let trimmedString = candidate.trimmingCharacters(in: .whitespaces) | |
// Check for the existence of any invalid characters. | |
// We invert validCharacterSet to represent every other character in unicode than what is valid. | |
// If rangeOfCharacter returns a value, we know that those characters exist (and therefore fails) | |
guard trimmedString.rangeOfCharacter(from: Self.validCharacterSet.inverted) == nil else { | |
// So we throw the appropriate error | |
throw ValidationError.invalidCharacters | |
} | |
// Convert any ISBN-10 values into ISBN13 values by adding | |
// the "Bookland" prefix (https://en.wikipedia.org/wiki/Bookland) | |
let isbn13String = trimmedString.count == 10 ? Self.booklandPrefix + trimmedString : trimmedString | |
// Run the ISBN 13 checksum calculation | |
// https://en.wikipedia.org/wiki/ISBN#ISBN-13_check_digit_calculation | |
// Use the reduce method to run the checksum, starting with 0 | |
// We enumerate the string because we need the position (it's offset) for each character, as | |
// well as the number itself. | |
// Start by removing all of the hyphens | |
let isbnString = isbn13String.components(separatedBy: .decimalDigits.inverted).joined() | |
// Verify that we have either 10 or 13 characters at this point. | |
guard [10, 13].contains(isbnString.count) else { | |
throw ValidationError.invalidStringLength | |
} | |
// First, we take the sum of the number. Multiplying each digit by either 1 or 3. | |
let sum = isbnString.enumerated().reduce(0) { partialResult, character in | |
// Safely convert the character into an integer. | |
guard let number = character.element.wholeNumberValue else { | |
return partialResult | |
} | |
// We alternate multiplying each character by 1 or 3 | |
let multiplier = character.offset % 2 == 0 ? 1 : 3 | |
// We then multiply the number by the multiplier, and add it to the previous result | |
return partialResult + (number * multiplier) | |
} | |
// We then make sure that the number is cleanly divisible by 10 by using the modulo function. | |
guard sum % 10 == 0 else { | |
throw ValidationError.checksumFailed | |
} | |
// Success. Return the original ISBN-10 or ISBN-13 string | |
return trimmedString | |
} | |
} | |
public extension ISBN.FormatStyle { | |
enum DecodingError: Error { | |
case invalidInput | |
} | |
struct ParseStrategy: Foundation.ParseStrategy { | |
public init() {} | |
public func parse(_ value: String) throws -> ISBN { | |
// Trim the input string any leading or trailing whitespaces | |
let trimmedValue = value.trimmingCharacters(in: .whitespaces) | |
// Attempt to validate our trimmed string | |
let validISBN = try ISBN.validate(trimmedValue) | |
// Create an array of strings based on the separator used. | |
let components = validISBN.components(separatedBy: ISBN.validSeparatorsSet) | |
// Having 4 components means that we were given an ISBN-10 number. | |
// Therefore we need to convert it. | |
let finalComponents = components.count == 4 ? [ISBN.booklandPrefix] + components : components | |
// Since we're going to use subscripts to access each value in the array, it's a good | |
// idea to verify that all values are present to avoid crashing. | |
guard finalComponents.count == 5 else { | |
throw DecodingError.invalidInput | |
} | |
// Build the final ISBN from the component parts. | |
return ISBN( | |
prefix: finalComponents[0], | |
registrationGroup: finalComponents[1], | |
registrant: finalComponents[2], | |
publication: finalComponents[3], | |
checkDigit: finalComponents[4] | |
) | |
} | |
} | |
} | |
// MARK: ParseableFormatStyle conformance on ISBN.FormatStyle | |
extension ISBN.FormatStyle: ParseableFormatStyle { | |
public var parseStrategy: ISBN.FormatStyle.ParseStrategy { | |
.init() | |
} | |
} | |
// MARK: Convenience members on ISBN to simplify access to the ParseStrategy | |
public extension ISBN { | |
init(_ string: String) throws { | |
self = try ISBN.FormatStyle().parseStrategy.parse(string) | |
} | |
init<T, Value>(_ value: Value, standard: T) throws where T: ParseStrategy, Value: StringProtocol, T.ParseInput == String, T.ParseOutput == ISBN { | |
self = try standard.parse(value.description) | |
} | |
} | |
// MARK: Extend ParseableFormatStyle to simplify access to the format style | |
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) | |
public extension ParseableFormatStyle where Self == ISBN.FormatStyle { | |
static var isbn: Self { .init() } | |
} |