From 86b9aed3c2484f7bdca15564807df59419273a9e Mon Sep 17 00:00:00 2001 From: Raphael Gruber Date: Sun, 28 Jun 2026 14:55:29 +0200 Subject: [PATCH 1/2] feat(shared): expose alternate identifiers via altIdentifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the RWPM altIdentifier model — already shipped in the go and ts toolkits — to Metadata and Contributor, surfacing identifiers other than the primary one. A new Shared AltIdentifier type ({value, scheme?}) parses from a bare URI string or an object and collapses back to a string when no scheme is set. For EPUB, Metadata.altIdentifiers is populated from every dc:identifier other than the package's unique-identifier, mirroring go-toolkit: values are returned as declared (only trimmed), in document order, with no scheme synthesis. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Shared/Publication/AltIdentifier.swift | 56 ++++++++++++++++++ Sources/Shared/Publication/Contributor.swift | 8 +++ Sources/Shared/Publication/Metadata.swift | 9 +++ .../Parser/EPUB/EPUBMetadataParser.swift | 11 ++++ .../Publication/AltIdentifierTests.swift | 58 +++++++++++++++++++ .../Publication/ContributorTests.swift | 16 +++++ .../Publication/MetadataTests.swift | 12 ++++ .../Fixtures/OPF/alt-identifier-epub2.opf | 15 +++++ .../Fixtures/OPF/alt-identifier-multiple.opf | 16 +++++ .../Fixtures/OPF/alt-identifier-urn.opf | 15 +++++ .../Parser/EPUB/EPUBMetadataParserTests.swift | 39 +++++++++++++ 11 files changed, 255 insertions(+) create mode 100644 Sources/Shared/Publication/AltIdentifier.swift create mode 100644 Tests/SharedTests/Publication/AltIdentifierTests.swift create mode 100644 Tests/StreamerTests/Fixtures/OPF/alt-identifier-epub2.opf create mode 100644 Tests/StreamerTests/Fixtures/OPF/alt-identifier-multiple.opf create mode 100644 Tests/StreamerTests/Fixtures/OPF/alt-identifier-urn.opf diff --git a/Sources/Shared/Publication/AltIdentifier.swift b/Sources/Shared/Publication/AltIdentifier.swift new file mode 100644 index 0000000000..f49e47f98e --- /dev/null +++ b/Sources/Shared/Publication/AltIdentifier.swift @@ -0,0 +1,56 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import ReadiumInternal + +/// An alternate identifier for a publication, i.e. an identifier other than the +/// primary ``Metadata/identifier``. +/// +/// https://readium.org/webpub-manifest/contexts/default/#identifier +/// https://readium.org/webpub-manifest/schema/altIdentifier.schema.json +public struct AltIdentifier: Hashable, Sendable, JSONValueDecodable, JSONValueEncodable { + /// The identifier value. + public var value: String + + /// URI of the identifier's scheme, when known (e.g. `urn:isbn`). + public var scheme: String? + + public init(value: String, scheme: String? = nil) { + self.value = value + self.scheme = scheme + } + + /// Parses an ``AltIdentifier`` from its RWPM JSON representation, which is + /// either a bare URI string or an object carrying a `value` and an optional + /// `scheme`. + public init?(json: T?, warnings: WarningLogger?) throws { + guard let json = json?.jsonValue else { + return nil + } + + if let value = json.string { + self.init(value: value) + } else if let object = json.object, let value = object["value"]?.string { + self.init(value: value, scheme: object["scheme"]?.string) + } else { + warnings?.log("Invalid AltIdentifier object", model: Self.self, source: json, severity: .minor) + throw JSONError.parsing(Self.self) + } + } + + /// Serializes to a bare string when no scheme is set – the schema's compact + /// form – or to an object otherwise. + public var jsonValue: JSONValue { + guard let scheme = scheme else { + return .string(value) + } + return .object([ + "value": .string(value), + "scheme": .string(scheme), + ]) + } +} diff --git a/Sources/Shared/Publication/Contributor.swift b/Sources/Shared/Publication/Contributor.swift index d5a0eaef6f..201c68fc83 100644 --- a/Sources/Shared/Publication/Contributor.swift +++ b/Sources/Shared/Publication/Contributor.swift @@ -18,6 +18,10 @@ public struct Contributor: Hashable, Sendable, JSONValueDecodable, JSONObjectEnc /// An unambiguous reference to this contributor. public var identifier: String? + /// Alternate identifiers for this contributor, other than the primary + /// ``identifier``. + public var altIdentifiers: [AltIdentifier] + /// The string used to sort the name of the contributor. public var sortAs: String? @@ -33,6 +37,7 @@ public struct Contributor: Hashable, Sendable, JSONValueDecodable, JSONObjectEnc public init( name: LocalizedStringConvertible, identifier: String? = nil, + altIdentifiers: [AltIdentifier] = [], sortAs: String? = nil, roles: [String] = [], role: String? = nil, @@ -47,6 +52,7 @@ public struct Contributor: Hashable, Sendable, JSONValueDecodable, JSONObjectEnc localizedName = name.localizedString self.identifier = identifier + self.altIdentifiers = altIdentifiers self.sortAs = sortAs self.roles = roles self.position = position @@ -68,6 +74,7 @@ public struct Contributor: Hashable, Sendable, JSONValueDecodable, JSONObjectEnc self.init( name: name, identifier: dict["identifier"]?.string, + altIdentifiers: dict["altIdentifier"]?.decode(allowingSingle: true, warnings: warnings) ?? [], sortAs: dict["sortAs"]?.string, roles: dict["role"]?.decode(allowingSingle: true) ?? [], position: dict["position"]?.double, @@ -83,6 +90,7 @@ public struct Contributor: Hashable, Sendable, JSONValueDecodable, JSONObjectEnc .init([ "name": localizedName, "identifier": identifier, + "altIdentifier": altIdentifiers.orNullIfEmpty, "sortAs": sortAs, "role": roles.orNullIfEmpty, "position": position, diff --git a/Sources/Shared/Publication/Metadata.swift b/Sources/Shared/Publication/Metadata.swift index 5ee37de2a1..cf97013e1e 100644 --- a/Sources/Shared/Publication/Metadata.swift +++ b/Sources/Shared/Publication/Metadata.swift @@ -17,6 +17,11 @@ public struct Metadata: Hashable, Loggable, WarningLogger, Sendable, JSONValueDe public typealias Collection = Contributor public var identifier: String? // URI + + /// Alternate identifiers for this publication, other than the primary + /// ``identifier``. + public var altIdentifiers: [AltIdentifier] + public var type: String? // URI (@type) public var conformsTo: [Publication.Profile] @@ -73,6 +78,7 @@ public struct Metadata: Hashable, Loggable, WarningLogger, Sendable, JSONValueDe public init( identifier: String? = nil, + altIdentifiers: [AltIdentifier] = [], type: String? = nil, conformsTo: [Publication.Profile] = [], title: LocalizedStringConvertible? = nil, @@ -108,6 +114,7 @@ public struct Metadata: Hashable, Loggable, WarningLogger, Sendable, JSONValueDe otherMetadata: [String: JSONValue] = [:] ) { self.identifier = identifier + self.altIdentifiers = altIdentifiers self.type = type self.conformsTo = conformsTo localizedTitle = title?.localizedString @@ -159,6 +166,7 @@ public struct Metadata: Hashable, Loggable, WarningLogger, Sendable, JSONValueDe } identifier = jsonObject.pop("identifier")?.string + altIdentifiers = jsonObject.pop("altIdentifier")?.decode(allowingSingle: true, warnings: warnings) ?? [] type = jsonObject.pop("@type")?.string ?? jsonObject.pop("type")?.string conformsTo = jsonObject.pop("conformsTo")?.decode(allowingSingle: true) ?? [] localizedTitle = title @@ -198,6 +206,7 @@ public struct Metadata: Hashable, Loggable, WarningLogger, Sendable, JSONValueDe public var jsonObject: [String: JSONValue] { .init([ "identifier": identifier, + "altIdentifier": altIdentifiers.orNullIfEmpty, "@type": type, "conformsTo": conformsTo.map(\.uri).orNullIfEmpty, "title": localizedTitle, diff --git a/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift b/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift index f00443f470..b3b94d3d93 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift @@ -44,6 +44,7 @@ final class EPUBMetadataParser: Loggable { return Metadata( identifier: uniqueIdentifier, + altIdentifiers: altIdentifiers, conformsTo: [.epub], title: mainTitle, subtitle: subtitle, @@ -323,6 +324,16 @@ final class EPUBMetadataParser: Loggable { dcElement(tag: "identifier[@id=/opf:package/@unique-identifier]")? .stringValue + /// Every `dc:identifier` other than the package's `unique-identifier`, + /// surfaced as ``Metadata/altIdentifiers``. Values are returned as declared + /// (only trimmed), in document order. + private lazy var altIdentifiers: [AltIdentifier] = { + let uniqueIdentifierID = document.firstChild(xpath: "/opf:package")?.attr("unique-identifier") + return metas["identifier", in: .dcterms] + .filter { $0.id != uniqueIdentifierID } + .map { AltIdentifier(value: $0.content) } + }() + /// https://github.com/readium/architecture/blob/master/streamer/parser/metadata.md#publication-date private lazy var publishedDate = dcElement(tag: "date[not(@opf:event) or @opf:event='publication']")? diff --git a/Tests/SharedTests/Publication/AltIdentifierTests.swift b/Tests/SharedTests/Publication/AltIdentifierTests.swift new file mode 100644 index 0000000000..0d1c7c1e74 --- /dev/null +++ b/Tests/SharedTests/Publication/AltIdentifierTests.swift @@ -0,0 +1,58 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +@testable import ReadiumShared +import XCTest + +class AltIdentifierTests: XCTestCase { + func testParseJSONString() { + XCTAssertEqual( + try? AltIdentifier(json: "urn:isbn:9781449325862"), + AltIdentifier(value: "urn:isbn:9781449325862") + ) + } + + func testParseMinimalJSON() { + XCTAssertEqual( + try? AltIdentifier(json: ["value": "9781449325862"]), + AltIdentifier(value: "9781449325862") + ) + } + + func testParseFullJSON() { + XCTAssertEqual( + try? AltIdentifier(json: [ + "value": "9781449325862", + "scheme": "urn:isbn", + ] as JSONValue), + AltIdentifier(value: "9781449325862", scheme: "urn:isbn") + ) + } + + func testParseJSONRequiresValue() { + XCTAssertThrowsError(try AltIdentifier(json: [ + "scheme": "urn:isbn", + ])) + } + + /// Without a scheme, the value collapses to a bare string. + func testGetMinimalJSON() { + XCTAssertEqual( + AltIdentifier(value: "urn:isbn:9781449325862").jsonValue, + .string("urn:isbn:9781449325862") + ) + } + + func testGetFullJSON() { + XCTAssertEqual( + AltIdentifier(value: "9781449325862", scheme: "urn:isbn").jsonValue, + [ + "value": "9781449325862", + "scheme": "urn:isbn", + ] as JSONValue + ) + } +} diff --git a/Tests/SharedTests/Publication/ContributorTests.swift b/Tests/SharedTests/Publication/ContributorTests.swift index b5b941da7a..52b520364b 100644 --- a/Tests/SharedTests/Publication/ContributorTests.swift +++ b/Tests/SharedTests/Publication/ContributorTests.swift @@ -27,6 +27,10 @@ class ContributorTests: XCTestCase { try? Contributor(json: [ "name": "Colin Greenwood", "identifier": "colin", + "altIdentifier": [ + "urn:isbn:9781449325862", + ["value": "1449325866", "scheme": "urn:isbn"], + ], "sortAs": "greenwood", "role": "bassist", "position": 4, @@ -38,6 +42,10 @@ class ContributorTests: XCTestCase { Contributor( name: "Colin Greenwood", identifier: "colin", + altIdentifiers: [ + AltIdentifier(value: "urn:isbn:9781449325862"), + AltIdentifier(value: "1449325866", scheme: "urn:isbn"), + ], sortAs: "greenwood", roles: ["bassist"], position: 4, @@ -80,6 +88,10 @@ class ContributorTests: XCTestCase { Contributor( name: ["en": "Jonny Greenwood", "fr": "Jean Boisvert"], identifier: "jonny", + altIdentifiers: [ + AltIdentifier(value: "urn:isbn:9781449325862"), + AltIdentifier(value: "1449325866", scheme: "urn:isbn"), + ], sortAs: "greenwood", roles: ["guitarist", "pianist"], position: 2.5, @@ -91,6 +103,10 @@ class ContributorTests: XCTestCase { [ "name": ["en": "Jonny Greenwood", "fr": "Jean Boisvert"], "identifier": "jonny", + "altIdentifier": [ + "urn:isbn:9781449325862", + ["value": "1449325866", "scheme": "urn:isbn"] as JSONValue, + ], "sortAs": "greenwood", "role": ["guitarist", "pianist"], "position": 2.5, diff --git a/Tests/SharedTests/Publication/MetadataTests.swift b/Tests/SharedTests/Publication/MetadataTests.swift index 26cab80f6a..a690054885 100644 --- a/Tests/SharedTests/Publication/MetadataTests.swift +++ b/Tests/SharedTests/Publication/MetadataTests.swift @@ -10,6 +10,10 @@ import XCTest class MetadataTests: XCTestCase { let fullMetadata = Metadata( identifier: "1234", + altIdentifiers: [ + AltIdentifier(value: "urn:isbn:9781449325862"), + AltIdentifier(value: "1449325866", scheme: "urn:isbn"), + ], type: "epub", conformsTo: [.epub, .pdf], title: ["en": "Title", "fr": "Titre"], @@ -71,6 +75,10 @@ class MetadataTests: XCTestCase { XCTAssertEqual( try Metadata(json: [ "identifier": "1234", + "altIdentifier": [ + "urn:isbn:9781449325862", + ["value": "1449325866", "scheme": "urn:isbn"], + ], "@type": "epub", "conformsTo": [ "https://readium.org/webpub-manifest/profiles/epub", @@ -180,6 +188,10 @@ class MetadataTests: XCTestCase { fullMetadata.jsonObject, [ "identifier": "1234", + "altIdentifier": [ + "urn:isbn:9781449325862", + ["value": "1449325866", "scheme": "urn:isbn"] as JSONValue, + ], "@type": "epub", "conformsTo": [ "https://readium.org/webpub-manifest/profiles/epub", diff --git a/Tests/StreamerTests/Fixtures/OPF/alt-identifier-epub2.opf b/Tests/StreamerTests/Fixtures/OPF/alt-identifier-epub2.opf new file mode 100644 index 0000000000..2ffca164de --- /dev/null +++ b/Tests/StreamerTests/Fixtures/OPF/alt-identifier-epub2.opf @@ -0,0 +1,15 @@ + + + + Programming Scala + urn:uuid:7408D53A-5383-40AA-8078-5256C872AE41 + 978-1-4493-2586-2 + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/OPF/alt-identifier-multiple.opf b/Tests/StreamerTests/Fixtures/OPF/alt-identifier-multiple.opf new file mode 100644 index 0000000000..2c79dee04e --- /dev/null +++ b/Tests/StreamerTests/Fixtures/OPF/alt-identifier-multiple.opf @@ -0,0 +1,16 @@ + + + + Programming Scala + urn:uuid:7408D53A-5383-40AA-8078-5256C872AE41 + 9781449325862 + 1449325866 + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/OPF/alt-identifier-urn.opf b/Tests/StreamerTests/Fixtures/OPF/alt-identifier-urn.opf new file mode 100644 index 0000000000..d4d769c083 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/OPF/alt-identifier-urn.opf @@ -0,0 +1,15 @@ + + + + Programming Scala + urn:uuid:7408D53A-5383-40AA-8078-5256C872AE41 + urn:isbn:9781449325862 + + + + + + + + diff --git a/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift b/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift index ff00b346f2..4263bf6116 100644 --- a/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift @@ -130,6 +130,45 @@ class EPUBMetadataParserTests: XCTestCase { XCTAssertEqual(sut.identifier, "urn:uuid:2") } + // MARK: - Alt Identifiers + + /// Every `dc:identifier` other than the package's `unique-identifier` is + /// surfaced as an alternate identifier. + func testParseAltIdentifiers() throws { + let sut = try parseMetadata("identifier-unique") + XCTAssertEqual(sut.altIdentifiers, [AltIdentifier(value: "urn:uuid:1")]) + } + + /// Alternate identifier values are returned exactly as declared – the + /// `opf:scheme` attribute is not interpreted and separators are preserved. + func testParseAltIdentifierValueIsVerbatim() throws { + let sut = try parseMetadata("alt-identifier-epub2") + XCTAssertEqual(sut.identifier, "urn:uuid:7408D53A-5383-40AA-8078-5256C872AE41") + XCTAssertEqual(sut.altIdentifiers, [AltIdentifier(value: "978-1-4493-2586-2")]) + } + + func testParseAltIdentifierURN() throws { + let sut = try parseMetadata("alt-identifier-urn") + XCTAssertEqual(sut.altIdentifiers, [AltIdentifier(value: "urn:isbn:9781449325862")]) + } + + /// A publication may declare several alternate identifiers; all are + /// returned, in document order. + func testParseMultipleAltIdentifiers() throws { + let sut = try parseMetadata("alt-identifier-multiple") + XCTAssertEqual(sut.altIdentifiers, [ + AltIdentifier(value: "9781449325862"), + AltIdentifier(value: "1449325866"), + ]) + } + + /// A publication whose only `dc:identifier` is the unique one has no + /// alternate identifiers. + func testParseNoAltIdentifiers() throws { + let sut = try parseMetadata("full-metadata") + XCTAssertEqual(sut.altIdentifiers, []) + } + func testParseDateEPUB3() throws { let sut = try parseMetadata("dates-epub3") XCTAssertEqual(sut.published, "1865-07-04".dateFromISO8601) From 7c833b8302a9c387f119b25a95d8224cd9e7916f Mon Sep 17 00:00:00 2001 From: Raphael Gruber Date: Sun, 28 Jun 2026 14:55:30 +0200 Subject: [PATCH 2/2] docs(changelog): add altIdentifier entry Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36d8c0c3e5..f73838fb39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. Take a look ## [Unreleased] +### Added + +#### Shared + +* `Metadata.altIdentifiers` and `Contributor.altIdentifiers` expose alternate identifiers (e.g. ISBNs) using the RWPM `altIdentifier` model. For EPUB, `Metadata.altIdentifiers` is parsed from every `dc:identifier` other than the package's unique identifier (contributed by [@raphi011](https://github.com/readium/swift-toolkit/pull/837)). + ### Fixed #### Navigator