diff --git a/.github/workflows/syndikit.yml b/.github/workflows/syndikit.yml index 377e675..3f5fcec 100644 --- a/.github/workflows/syndikit.yml +++ b/.github/workflows/syndikit.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: runs-on: [ubuntu-20.04, ubuntu-22.04] - swift-version: [5.7.3, 5.8] + swift-version: [5.7.3, 5.8.1, 5.9] include: - runs-on: ubuntu-20.04 swift-version: 5.5.3 @@ -30,14 +30,13 @@ jobs: id: cache-spm-linux uses: actions/cache@v3 env: - cache-name: cache-spm + cache-name: SPM with: path: .build - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('Package.resolved') }} + key: ${{ env.cache-name }}-${{ runner.os }}-${{ env.SWIFT_VER }}-${{ hashFiles('Package.resolved') }}-${{ env.RELEASE_DOT }} restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}- - ${{ runner.os }}-build- - ${{ runner.os }}- + ${{ env.cache-name }}-${{ runner.os }}-${{ env.SWIFT_VER }}-${{ hashFiles('Package.resolved') }} + ${{ env.cache-name }}-${{ runner.os }}-${{ env.SWIFT_VER }} - name: Set Ubuntu Release DOT run: echo "RELEASE_DOT=$(lsb_release -sr)" >> $GITHUB_ENV - name: Set Ubuntu Release NUM @@ -48,14 +47,10 @@ jobs: id: cache-swift-linux uses: actions/cache@v3 env: - cache-name: cache-swift + cache-name: swift with: path: swift-${{ env.SWIFT_VER }}-RELEASE-ubuntu${{ env.RELEASE_DOT }} - key: ${{ runner.os }}-${{ env.RELEASE_DOT }}-${{ env.cache-name }}-${{ env.SWIFT_VER }} - restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}- - ${{ runner.os }}-build- - ${{ runner.os }}- + key: ${{ env.cache-name }}-${{ runner.os }}-${{ env.SWIFT_VER }}-${{ env.RELEASE_DOT }} - name: Download Swift if: steps.cache-swift-linux.outputs.cache-hit != 'true' run: curl -O https://download.swift.org/swift-${SWIFT_VER}-release/ubuntu${RELEASE_NUM}/swift-${SWIFT_VER}-RELEASE/swift-${SWIFT_VER}-RELEASE-ubuntu${RELEASE_DOT}.tar.gz @@ -71,7 +66,7 @@ jobs: - name: Prepare Code Coverage run: llvm-cov export -format="lcov" .build/x86_64-unknown-linux-gnu/debug/${{ env.PACKAGE_NAME }}PackageTests.xctest -instr-profile .build/debug/codecov/default.profdata > info.lcov - name: Upload to CodeCov.io - run: bash <(curl https://codecov.io/bash) -F github -F ${RELEASE_NAME} -F ${SWIFT_VER} + run: bash <(curl https://codecov.io/bash) -F spm -F ${RELEASE_NAME} -F ${SWIFT_VER} env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} build-macos: @@ -114,11 +109,17 @@ jobs: watchName: "Apple Watch Ultra (49mm)" iPhoneName: "iPhone 14" - runs-on: macos-13 - xcode: "/Applications/Xcode_14.3.app" + xcode: "/Applications/Xcode_14.3.1.app" iOSVersion: "16.4" watchOSVersion: "9.4" watchName: "Apple Watch Ultra (49mm)" iPhoneName: "iPhone 14 Pro Max" + - runs-on: macos-13 + xcode: "/Applications/Xcode_15.0.app" + iOSVersion: "17.0" + watchOSVersion: "10.0" + watchName: "Apple Watch Ultra (49mm)" + iPhoneName: "iPhone 14 Pro Max" steps: - uses: actions/checkout@v3 - name: Cache swift package modules @@ -134,7 +135,7 @@ jobs: ${{ runner.os }}-build- ${{ runner.os }}- - name: Cache mint - if: ${{ github.event_name == 'pull_request' && github.base_ref == 'main' && matrix.xcode == '/Applications/Xcode_14.1.app' }} + if: ${{ github.event_name == 'pull_request' && ( github.base_ref == 'main' || endsWith( github.ref_name , 'Prep') ) && matrix.xcode == '/Applications/Xcode_14.3.1.app' }} id: cache-mint uses: actions/cache@v3 env: @@ -151,33 +152,32 @@ jobs: - name: Setup Xcode run: sudo xcode-select -s ${{ matrix.xcode }}/Contents/Developer - name: Install mint - if: ${{ github.event_name == 'pull_request' && github.base_ref == 'main' && matrix.xcode == '/Applications/Xcode_14.1.app' }} + if: ${{ github.event_name == 'pull_request' && ( github.base_ref == 'main' || endsWith( github.ref_name , 'Prep') ) && matrix.xcode == '/Applications/Xcode_14.3.1.app' }} run: | brew update brew install mint - name: Initialize CodeQL + if: startsWith(matrix.xcode,'/Applications/Xcode_14.3.1') uses: github/codeql-action/init@v2 with: languages: ${{ matrix.language }} - name: Build run: swift build - name: Perform CodeQL Analysis + if: startsWith(matrix.xcode,'/Applications/Xcode_14.3.1') uses: github/codeql-action/analyze@v2 - name: Run Swift Package tests run: swift test -v --enable-code-coverage - - uses: sersoft-gmbh/swift-coverage-action@v2 - - name: Upload SPM coverage to Codecov - uses: codecov/codecov-action@v2 - with: - fail_ci_if_error: true - flags: spm - verbose: true - token: ${{ secrets.CODECOV_TOKEN }} + - uses: sersoft-gmbh/swift-coverage-action@v4 + - name: Upload SPM to CodeCov.io + run: bash <(curl https://codecov.io/bash) -F spm -F macOS -F ${XCODE_NAME} + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - name: Clean up spm build directory run: rm -rf .build - name: Lint run: ./scripts/lint.sh - if: ${{ github.event_name == 'pull_request' && github.base_ref == 'main' && matrix.xcode == '/Applications/Xcode_14.3.app' }} + if: ${{ github.event_name == 'pull_request' && ( github.base_ref == 'main' || endsWith( github.ref_name , 'Prep') ) && matrix.xcode == '/Applications/Xcode_14.3.1.app' }} - name: Dump PIF if: startsWith(matrix.xcode,'/Applications/Xcode_14') run: | @@ -190,38 +190,16 @@ jobs: done - name: Run iOS target tests run: xcodebuild test -scheme SyndiKit -sdk iphonesimulator -destination 'platform=iOS Simulator,name=${{ matrix.iPhoneName }},OS=${{ matrix.iOSVersion }}' -enableCodeCoverage YES build test - - uses: sersoft-gmbh/swift-coverage-action@v2 - - name: Upload iOS coverage to Codecov - uses: codecov/codecov-action@v2 - with: - fail_ci_if_error: true - flags: iOS,iOS-${{ matrix.iOSVersion }} - verbose: true - token: ${{ secrets.CODECOV_TOKEN }} + - uses: sersoft-gmbh/swift-coverage-action@v4 + - name: Upload iOS Coverage to CodeCov.io + run: bash <(curl https://codecov.io/bash) -F iOS -F iOS${{ matrix.iOSVersion }} -F macOS -F ${XCODE_NAME} + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - name: Run watchOS target tests run: xcodebuild test -scheme SyndiKit -sdk watchsimulator -destination 'platform=watchOS Simulator,name=${{ matrix.watchName }},OS=${{ matrix.watchOSVersion }}' -enableCodeCoverage YES build test - - uses: sersoft-gmbh/swift-coverage-action@v2 - - name: Upload watchOS coverage to Codecov - uses: codecov/codecov-action@v2 - with: - fail_ci_if_error: true - flags: watchOS,watchOS${{ matrix.watchOSVersion }} - verbose: true + - uses: sersoft-gmbh/swift-coverage-action@v4 + - name: Upload watchOS Coverage to CodeCov.io + run: bash <(curl https://codecov.io/bash) -F watchOS -F watchOS${{ matrix.watchOSVersion }} -F macOS -F ${XCODE_NAME} + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }} - deploy: - name: Deploy to Netlify - needs: [build-macos, build-ubuntu] - env: - PACKAGE_NAME: SyndiKit - runs-on: macos-12 - if: ${{ github.ref == 'refs/heads/main' }} - steps: - - uses: actions/checkout@v2 - - name: Setup Netlify - run: brew install netlify-cli - - name: Resolve Package Dependencies - run: xcodebuild -resolvePackageDependencies -scheme ${{ env.PACKAGE_NAME }} -derivedDataPath DerivedData - - name: Build DocC Documentation - run: xcodebuild docbuild -scheme ${{ env.PACKAGE_NAME }} -destination 'platform=macOS' -derivedDataPath DerivedData - - name: Deploy Files - run: netlify deploy --site ${{ secrets.NETLIFY_SITE_ID }} --auth ${{ secrets.NETLIFY_AUTH_TOKEN }} --prod diff --git a/.swiftlint.yml b/.swiftlint.yml index 9b8e635..db96f2b 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -114,9 +114,8 @@ identifier_name: excluded: - id excluded: - - Tests/*/XCTestManifests.swift + - Tests - DerivedData - .build - - Tests/LinuxMain.swift indentation_width: indentation_width: 2 diff --git a/Data/OPML/category.opml b/Data/OPML/category.opml new file mode 100644 index 0000000..5121bdc --- /dev/null +++ b/Data/OPML/category.opml @@ -0,0 +1,10 @@ + + + + Illustrating the category attribute + Mon, 31 Oct 2005 19:23:00 GMT + + + + + diff --git a/Data/OPML/category_invalidExpansionState.opml b/Data/OPML/category_invalidExpansionState.opml new file mode 100644 index 0000000..21e6ba6 --- /dev/null +++ b/Data/OPML/category_invalidExpansionState.opml @@ -0,0 +1,11 @@ + + + + Illustrating the category attribute + Mon, 31 Oct 2005 19:23:00 GMT + one, two, three + + + + + diff --git a/Data/OPML/development.opml b/Data/OPML/development.opml new file mode 100644 index 0000000..a5c7f00 --- /dev/null +++ b/Data/OPML/development.opml @@ -0,0 +1,673 @@ + + + + Development Blogs (English Language) from the iOS Dev Directory (http://iosdevdirectory.com) + Mon, 11 Sep 2023 08:09:22 +0000 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Data/OPML/directory.opml b/Data/OPML/directory.opml new file mode 100644 index 0000000..230831b --- /dev/null +++ b/Data/OPML/directory.opml @@ -0,0 +1,26 @@ + + + + scriptingNewsDirectory.opml + Thu, 13 Oct 2005 15:34:07 GMT + Tue, 25 Oct 2005 21:33:57 GMT + Dave Winer + dwiner@yahoo.com + + 1 + 105 + 466 + 386 + 964 + + + + + + + + + + + + diff --git a/Data/OPML/placesLived.opml b/Data/OPML/placesLived.opml new file mode 100644 index 0000000..430231b --- /dev/null +++ b/Data/OPML/placesLived.opml @@ -0,0 +1,43 @@ + + + + placesLived.opml + Mon, 27 Feb 2006 12:09:48 GMT + Mon, 27 Feb 2006 12:11:44 GMT + Dave Winer + http://www.opml.org/profiles/sendMail?usernum=1 + 1, 2, 5, 10, 13, 15 + 1 + 242 + 329 + 665 + 547 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Data/OPML/simpleScript.opml b/Data/OPML/simpleScript.opml new file mode 100644 index 0000000..b0c5612 --- /dev/null +++ b/Data/OPML/simpleScript.opml @@ -0,0 +1,34 @@ + + + + workspace.userlandsamples.doSomeUpstreaming + Mon, 11 Feb 2002 22:48:02 GMT + Sun, 30 Oct 2005 03:30:17 GMT + Dave Winer + dwiner@yahoo.com + 1, 2, 4 + 1 + 74 + 41 + 314 + 475 + + + + + + + + + + + + + + + + + + + + diff --git a/Data/OPML/states.opml b/Data/OPML/states.opml new file mode 100644 index 0000000..360a171 --- /dev/null +++ b/Data/OPML/states.opml @@ -0,0 +1,91 @@ + + + + states.opml + Tue, 15 Mar 2005 16:35:45 GMT + Thu, 14 Jul 2005 23:41:05 GMT + Dave Winer + dave@scripting.com + 1, 6, 13, 16, 18, 20 + 1 + 106 + 106 + 558 + 479 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Data/OPML/subscriptionList.opml b/Data/OPML/subscriptionList.opml new file mode 100644 index 0000000..f8bba8e --- /dev/null +++ b/Data/OPML/subscriptionList.opml @@ -0,0 +1,40 @@ + + + + mySubscriptions.opml + Sat, 18 Jun 2005 12:11:52 GMT + Tue, 02 Aug 2005 21:42:48 GMT + Dave Winer + dave@scripting.com + + 1 + 61 + 304 + 562 + 842 + + + + + + + + + + + + + + + + + diff --git a/Sources/SyndiKit/Character.swift b/Sources/SyndiKit/Character.swift new file mode 100644 index 0000000..6bbf343 --- /dev/null +++ b/Sources/SyndiKit/Character.swift @@ -0,0 +1,7 @@ +import Foundation + +extension Character { + func asOsmType() -> PodcastLocation.OsmQuery.OsmType? { + .init(rawValue: String(self)) + } +} diff --git a/Sources/SyndiKit/Collection.swift b/Sources/SyndiKit/Collection.swift new file mode 100644 index 0000000..a0beab0 --- /dev/null +++ b/Sources/SyndiKit/Collection.swift @@ -0,0 +1,7 @@ +import Foundation + +extension Collection { + subscript(safe index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/Sources/SyndiKit/Common/Feedable.swift b/Sources/SyndiKit/Common/Feedable.swift index 2ed0cc0..21ddcba 100644 --- a/Sources/SyndiKit/Common/Feedable.swift +++ b/Sources/SyndiKit/Common/Feedable.swift @@ -9,7 +9,7 @@ import Foundation /// - ``siteURL`` /// - ``summary`` /// - ``updated`` -/// - ``author`` +/// - ``authors`` /// - ``copyright`` /// - ``image`` /// - ``children`` diff --git a/Sources/SyndiKit/Common/Primitives/ListString.swift b/Sources/SyndiKit/Common/Primitives/ListString.swift new file mode 100644 index 0000000..58b66db --- /dev/null +++ b/Sources/SyndiKit/Common/Primitives/ListString.swift @@ -0,0 +1,39 @@ +import Foundation + +public struct ListString< + Value: LosslessStringConvertible & Equatable +>: Codable, Equatable { + public let values: [Value] + + internal init(values: [Value]) { + self.values = values + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let listString = try container.decode(String.self) + let strings = listString.components(separatedBy: ",") + let values = try strings + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .map(Self.createValueFrom) + self.init(values: values) + } + + private static func createValueFrom(_ string: String) throws -> Value { + guard let value: Value = .init(string) else { + throw DecodingError.typeMismatch( + Value.self, + .init(codingPath: [], debugDescription: "Invalid value: \(string)") + ) + } + return value + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + let strings = values.map(String.init) + let listString = strings.joined(separator: ",") + try container.encode(listString) + } +} diff --git a/Sources/SyndiKit/Formats/Feeds/RSS/RSSChannel.swift b/Sources/SyndiKit/Formats/Feeds/RSS/RSSChannel.swift index 331ba2b..26a4327 100644 --- a/Sources/SyndiKit/Formats/Feeds/RSS/RSSChannel.swift +++ b/Sources/SyndiKit/Formats/Feeds/RSS/RSSChannel.swift @@ -45,6 +45,10 @@ public struct RSSChannel: Codable { public let wpBaseSiteURL: URL? public let wpBaseBlogURL: URL? + public let podcastLocked: PodcastLocked? + public let podcastFundings: [PodcastFunding] + public let podcastPeople: [PodcastPerson] + enum CodingKeys: String, CodingKey { case title case link @@ -65,6 +69,9 @@ public struct RSSChannel: Codable { case wpTags = "wp:tag" case wpBaseSiteURL = "wp:baseSiteUrl" case wpBaseBlogURL = "wp:baseBlogUrl" + case podcastLocked = "podcast:locked" + case podcastFundings = "podcast:funding" + case podcastPeople = "podcast:person" } } diff --git a/Sources/SyndiKit/Formats/Feeds/RSS/RSSItem.swift b/Sources/SyndiKit/Formats/Feeds/RSS/RSSItem.swift index 18a6052..647bebb 100644 --- a/Sources/SyndiKit/Formats/Feeds/RSS/RSSItem.swift +++ b/Sources/SyndiKit/Formats/Feeds/RSS/RSSItem.swift @@ -18,7 +18,11 @@ public struct RSSItem: Codable { public let itunesExplicit: String? public let itunesDuration: iTunesDuration? public let itunesImage: iTunesImage? - public let podcastPerson: [PodcastPerson]? + public let podcastPeople: [PodcastPerson] + public let podcastTranscripts: [PodcastTranscript] + public let podcastChapters: PodcastChapters? + public let podcastSoundbites: [PodcastSoundbite] + public let podcastSeason: PodcastSeason? public let enclosure: Enclosure? public let creators: [String] public let wpCommentStatus: CData? @@ -35,7 +39,7 @@ public struct RSSItem: Codable { public let wpModifiedDateGMT: Date? public let wpPostName: CData? public let wpPostType: CData? - public let wpPostMeta: [WordPressElements.PostMeta]? + public let wpPostMeta: [WordPressElements.PostMeta] public let wpAttachmentURL: URL? public let mediaContent: AtomMedia? public let mediaThumbnail: AtomMedia? @@ -58,7 +62,11 @@ public struct RSSItem: Codable { itunesExplicit: String? = nil, itunesDuration: TimeInterval? = nil, itunesImage: iTunesImage? = nil, - podcastPerson: [PodcastPerson]? = nil, + podcastPeople: [PodcastPerson] = [], + podcastTranscripts: [PodcastTranscript] = [], + podcastChapters: PodcastChapters? = nil, + podcastSoundbites: [PodcastSoundbite] = [], + podcastSeason: PodcastSeason? = nil, enclosure: Enclosure? = nil, creators: [String] = [], wpCommentStatus: String? = nil, @@ -75,7 +83,7 @@ public struct RSSItem: Codable { wpModifiedDateGMT: Date? = nil, wpPostName: String? = nil, wpPostType: String? = nil, - wpPostMeta: [WordPressElements.PostMeta]? = nil, + wpPostMeta: [WordPressElements.PostMeta] = [], wpAttachmentURL: URL? = nil, mediaContent: AtomMedia? = nil, mediaThumbnail: AtomMedia? = nil @@ -96,7 +104,11 @@ public struct RSSItem: Codable { self.itunesExplicit = itunesExplicit self.itunesDuration = itunesDuration.map(iTunesDuration.init) self.itunesImage = itunesImage - self.podcastPerson = podcastPerson + self.podcastPeople = podcastPeople + self.podcastTranscripts = podcastTranscripts + self.podcastChapters = podcastChapters + self.podcastSoundbites = podcastSoundbites + self.podcastSeason = podcastSeason self.enclosure = enclosure self.creators = creators self.wpCommentStatus = wpCommentStatus.map(CData.init) @@ -143,9 +155,26 @@ public struct RSSItem: Codable { ) itunesImage = try container.decodeIfPresent(iTunesImage.self, forKey: .itunesImage) - podcastPerson = try container.decodeIfPresent( + podcastPeople = try container.decodeIfPresent( [PodcastPerson].self, - forKey: .podcastPerson + forKey: .podcastPeople + ) ?? [] + podcastTranscripts = try container.decodeIfPresent( + [PodcastTranscript].self, + forKey: .podcastTranscripts + ) ?? [] + podcastChapters = try container.decodeIfPresent( + PodcastChapters.self, + forKey: .podcastChapters + ) + podcastSoundbites = try container.decodeIfPresent( + [PodcastSoundbite].self, + forKey: .podcastSoundbites + ) ?? [] + + podcastSeason = try container.decodeIfPresent( + PodcastSeason.self, + forKey: .podcastSeason ) enclosure = try container.decodeIfPresent(Enclosure.self, forKey: .enclosure) @@ -203,7 +232,7 @@ public struct RSSItem: Codable { wpPostMeta = try container.decodeIfPresent( [WordPressElements.PostMeta].self, forKey: .wpPostMeta - ) + ) ?? [] wpCommentStatus = try container.decodeIfPresent(CData.self, forKey: .wpCommentStatus) wpPingStatus = try container.decodeIfPresent(CData.self, forKey: .wpPingStatus) wpStatus = try container.decodeIfPresent(CData.self, forKey: .wpStatus) @@ -231,7 +260,11 @@ public struct RSSItem: Codable { case itunesSubtitle = "itunes:subtitle" case itunesSummary = "itunes:summary" case itunesExplicit = "itunes:explicit" - case podcastPerson = "podcast:person" + case podcastPeople = "podcast:person" + case podcastTranscripts = "podcast:transcript" + case podcastChapters = "podcast:chapters" + case podcastSoundbites = "podcast:soundbite" + case podcastSeason = "podcast:season" case itunesDuration = "itunes:duration" case itunesImage = "itunes:image" case creators = "dc:creator" diff --git a/Sources/SyndiKit/Formats/Media/Podcast/PodcastChapters+MimeType.swift b/Sources/SyndiKit/Formats/Media/Podcast/PodcastChapters+MimeType.swift new file mode 100644 index 0000000..4b57a7e --- /dev/null +++ b/Sources/SyndiKit/Formats/Media/Podcast/PodcastChapters+MimeType.swift @@ -0,0 +1,54 @@ +import Foundation + +extension PodcastChapters { + private enum KnownMimeType: String, Codable { + case json = "application/json+chapters" + + init?(caseInsensitive: String) { + self.init(rawValue: caseInsensitive) + } + + init?(mimeType: MimeType) { + switch mimeType { + case .json: self = .json + case .unknown: return nil + } + } + } + + public enum MimeType: Codable, Equatable, RawRepresentable { + case json + case unknown(String) + + public var rawValue: String { + if let knownMimeType = KnownMimeType(mimeType: self) { + return knownMimeType.rawValue + } else if case let .unknown(string) = self { + return string + } else { + fatalError( + // swiftlint:disable:next line_length + "Type attribute of with value: \(self) should either be `KnownMimeType`, or unknown!" + ) + } + } + + public init?(rawValue: String) { + self.init(caseInsensitive: rawValue) + } + + public init(caseInsensitive: String) { + if let knownMimeType = KnownMimeType(caseInsensitive: caseInsensitive) { + self = .init(knownMimeType: knownMimeType) + } else { + self = .unknown(caseInsensitive) + } + } + + private init(knownMimeType: KnownMimeType) { + switch knownMimeType { + case .json: self = .json + } + } + } +} diff --git a/Sources/SyndiKit/Formats/Media/Podcast/PodcastChapters.swift b/Sources/SyndiKit/Formats/Media/Podcast/PodcastChapters.swift new file mode 100644 index 0000000..561012e --- /dev/null +++ b/Sources/SyndiKit/Formats/Media/Podcast/PodcastChapters.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct PodcastChapters: Codable, Equatable { + public let url: URL + public let type: MimeType + + enum CodingKeys: String, CodingKey { + case url + case type + } +} diff --git a/Sources/SyndiKit/Formats/Media/PodcastEpisode.swift b/Sources/SyndiKit/Formats/Media/Podcast/PodcastEpisode.swift similarity index 96% rename from Sources/SyndiKit/Formats/Media/PodcastEpisode.swift rename to Sources/SyndiKit/Formats/Media/Podcast/PodcastEpisode.swift index 97bca65..d5b3488 100644 --- a/Sources/SyndiKit/Formats/Media/PodcastEpisode.swift +++ b/Sources/SyndiKit/Formats/Media/Podcast/PodcastEpisode.swift @@ -1,4 +1,5 @@ import Foundation + public protocol PodcastEpisode { var title: String? { get } var episode: Int? { get } @@ -37,6 +38,6 @@ struct PodcastEpisodeProperties: PodcastEpisode { duration = rssItem.itunesDuration?.value image = rssItem.itunesImage self.enclosure = enclosure - people = rssItem.podcastPerson ?? [] + people = rssItem.podcastPeople } } diff --git a/Sources/SyndiKit/Formats/Media/Podcast/PodcastFunding.swift b/Sources/SyndiKit/Formats/Media/Podcast/PodcastFunding.swift new file mode 100644 index 0000000..227ccb4 --- /dev/null +++ b/Sources/SyndiKit/Formats/Media/Podcast/PodcastFunding.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct PodcastFunding: Codable, Equatable { + public let url: URL + public let description: String? + + enum CodingKeys: String, CodingKey { + case url + case description = "" + } +} diff --git a/Sources/SyndiKit/Formats/Media/Podcast/PodcastLocation+GeoURI.swift b/Sources/SyndiKit/Formats/Media/Podcast/PodcastLocation+GeoURI.swift new file mode 100644 index 0000000..a5bbe92 --- /dev/null +++ b/Sources/SyndiKit/Formats/Media/Podcast/PodcastLocation+GeoURI.swift @@ -0,0 +1,106 @@ +import Foundation + +public extension PodcastLocation { + struct GeoURI: Codable, Equatable, LosslessStringConvertible { + public let latitude: Double + public let longitude: Double + public let altitude: Double? + public let accuracy: Double? + + public var description: String { + var description = "geo:\(latitude),\(longitude)" + + if let altitude = altitude { + description += ",\(altitude)" + } + + if let accuracy = accuracy { + description += ";u=\(accuracy)" + } + + return description + } + + public init( + latitude: Double, + longitude: Double, + altitude: Double? = nil, + accuracy: Double? = nil + ) { + self.latitude = latitude + self.longitude = longitude + self.altitude = altitude + self.accuracy = accuracy + } + + public init?(_ description: String) { + try? self.init(singleValue: description) + } + + public init(singleValue: String) throws { + let pathComponents = try Self.pathComponents(from: singleValue) + + guard + let geoCoords = pathComponents[safe: 0]?.split(separator: ","), + let latitude = geoCoords[safe: 0]?.asDouble(), + let longitude = geoCoords[safe: 1]?.asDouble() + else { + throw DecodingError.dataCorrupted( + .init( + codingPath: [PodcastLocation.CodingKeys.geo], + debugDescription: "Invalid coordinates for geo attribute: \(singleValue)" + ) + ) + } + + let altitude = geoCoords[safe: 2]?.asDouble() + + let accuracy = pathComponents[safe: 1]? + .split(separator: "=")[safe: 1]? + .asDouble() + + self.init( + latitude: latitude, + longitude: longitude, + altitude: altitude, + accuracy: accuracy + ) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let singleValue = try container.decode(String.self) + + try self.init(singleValue: singleValue) + } + + private static func pathComponents(from string: String) throws -> [Substring] { + let components = string.split(separator: ":") + + guard + components[safe: 0] == "geo" else { + throw DecodingError.dataCorrupted( + .init( + codingPath: [PodcastLocation.CodingKeys.geo], + debugDescription: "Invalid prefix for geo attribute: \(string)" + ) + ) + } + guard let geoPath = components[safe: 1] else { + throw DecodingError.dataCorrupted( + .init( + codingPath: [PodcastLocation.CodingKeys.geo], + debugDescription: "Invalid path for geo attribute: \(string)" + ) + ) + } + + return geoPath.split(separator: ";") + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(description) + } + } +} diff --git a/Sources/SyndiKit/Formats/Media/Podcast/PodcastLocation+OsmQuery.swift b/Sources/SyndiKit/Formats/Media/Podcast/PodcastLocation+OsmQuery.swift new file mode 100644 index 0000000..6eaa0de --- /dev/null +++ b/Sources/SyndiKit/Formats/Media/Podcast/PodcastLocation+OsmQuery.swift @@ -0,0 +1,47 @@ +import Foundation + +public extension PodcastLocation { + struct OsmQuery: Codable, Equatable { + enum OsmType: String, Codable, CaseIterable { + case node = "N" + case way = "W" + case relation = "R" + + static func isValid(_ rawValue: String) -> Bool { + OsmType(rawValue: rawValue) != nil + } + } + + let id: Int + let type: OsmType + let revision: Int? + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + var osmStr = try container.decode(String.self) + + guard let osmType = osmStr.removeFirst().asOsmType() else { + throw DecodingError.dataCorrupted( + .init( + codingPath: [PodcastLocation.CodingKeys.osmQuery], + debugDescription: "Invalid type for osm attribute: \(osmStr)" + ) + ) + } + guard let osmID = osmStr.split(separator: "#")[safe: 0]?.asExactInt() else { + throw DecodingError.dataCorrupted( + .init( + codingPath: [PodcastLocation.CodingKeys.osmQuery], + debugDescription: "Invalid id of type Int for osm attribute: \(osmStr)" + ) + ) + } + let osmRevision = osmStr.split(separator: "#")[safe: 1]?.asInt() + + id = osmID + type = osmType + revision = osmRevision + } + } +} diff --git a/Sources/SyndiKit/Formats/Media/Podcast/PodcastLocation.swift b/Sources/SyndiKit/Formats/Media/Podcast/PodcastLocation.swift new file mode 100644 index 0000000..df8c1c1 --- /dev/null +++ b/Sources/SyndiKit/Formats/Media/Podcast/PodcastLocation.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct PodcastLocation: Codable, Equatable { + public let geo: GeoURI? + public let osmQuery: OsmQuery? + + public let name: String + + enum CodingKeys: String, CodingKey { + case geo + case osmQuery = "osm" + + case name = "" + } +} diff --git a/Sources/SyndiKit/Formats/Media/Podcast/PodcastLocked.swift b/Sources/SyndiKit/Formats/Media/Podcast/PodcastLocked.swift new file mode 100644 index 0000000..b35c891 --- /dev/null +++ b/Sources/SyndiKit/Formats/Media/Podcast/PodcastLocked.swift @@ -0,0 +1,17 @@ +import Foundation + +public struct PodcastLocked: Codable, Equatable { + public let owner: String? + public let isLocked: Bool + + enum CodingKeys: String, CodingKey { + case owner + case isLocked = "" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + owner = try container.decodeIfPresent(String.self, forKey: .owner) + isLocked = try container.decode(String.self, forKey: .isLocked).lowercased() == "yes" + } +} diff --git a/Sources/SyndiKit/Formats/Media/Podcast/PodcastPerson+Role.swift b/Sources/SyndiKit/Formats/Media/Podcast/PodcastPerson+Role.swift new file mode 100644 index 0000000..d3359d1 --- /dev/null +++ b/Sources/SyndiKit/Formats/Media/Podcast/PodcastPerson+Role.swift @@ -0,0 +1,78 @@ +import Foundation + +extension PodcastPerson { + private enum KnownRole: String { + case guest + case host + case editor + case writer + case designer + case composer + case producer + + init?(caseInsensitive: String) { + self.init(rawValue: caseInsensitive.lowercased()) + } + + init?(role: Role) { + switch role { + case .guest: self = .guest + case .host: self = .host + case .editor: self = .editor + case .writer: self = .writer + case .designer: self = .designer + case .composer: self = .composer + case .producer: self = .producer + case .unknown: return nil + } + } + } + + public enum Role: Codable, Equatable, RawRepresentable { + case guest + case host + case editor + case writer + case designer + case composer + case producer + case unknown(String) + + public var rawValue: String { + if let knownRole = KnownRole(role: self) { + return knownRole.rawValue + } else if case let .unknown(string) = self { + return string + } else { + fatalError( + // swiftlint:disable:next line_length + "Role attribute of with value: \(self) should either be a `KnownRole`, or unknown!" + ) + } + } + + public init?(rawValue: String) { + self.init(caseInsensitive: rawValue) + } + + public init(caseInsensitive: String) { + if let knownRole = KnownRole(caseInsensitive: caseInsensitive) { + self = .init(knownRole: knownRole) + } else { + self = .unknown(caseInsensitive) + } + } + + private init(knownRole: KnownRole) { + switch knownRole { + case .guest: self = .guest + case .host: self = .host + case .editor: self = .editor + case .writer: self = .writer + case .designer: self = .designer + case .composer: self = .composer + case .producer: self = .producer + } + } + } +} diff --git a/Sources/SyndiKit/Formats/Media/Podcast/PodcastPerson.swift b/Sources/SyndiKit/Formats/Media/Podcast/PodcastPerson.swift index 45494ab..8ee3322 100644 --- a/Sources/SyndiKit/Formats/Media/Podcast/PodcastPerson.swift +++ b/Sources/SyndiKit/Formats/Media/Podcast/PodcastPerson.swift @@ -1,18 +1,31 @@ import Foundation -public struct PodcastPerson: Codable { - public let email: String? - public let role: String - public let href: String +public struct PodcastPerson: Codable, Equatable { + public let role: Role? + public let group: String? + public let href: URL? public let img: URL? - public let name: String + public let fullname: String enum CodingKeys: String, CodingKey { - case email case role + case group case href case img - case name = "" + case fullname = "" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + role = try container.decodeIfPresent(Role.self, forKey: .role) + group = try container.decodeIfPresent(String.self, forKey: .group) + fullname = try container.decode(String.self, forKey: .fullname) + + let hrefUrl = try container.decodeIfPresent(String.self, forKey: .href) ?? "" + href = hrefUrl.isEmpty ? nil : URL(string: hrefUrl) + + let imgUrl = try container.decodeIfPresent(String.self, forKey: .img) ?? "" + img = imgUrl.isEmpty ? nil : URL(string: imgUrl) } } diff --git a/Sources/SyndiKit/Formats/Media/Podcast/PodcastSeason.swift b/Sources/SyndiKit/Formats/Media/Podcast/PodcastSeason.swift new file mode 100644 index 0000000..e18ba77 --- /dev/null +++ b/Sources/SyndiKit/Formats/Media/Podcast/PodcastSeason.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct PodcastSeason: Codable, Equatable { + public let name: String? + public let number: Int + + enum CodingKeys: String, CodingKey { + case name + case number = "" + } +} diff --git a/Sources/SyndiKit/Formats/Media/Podcast/PodcastSoundbite.swift b/Sources/SyndiKit/Formats/Media/Podcast/PodcastSoundbite.swift new file mode 100644 index 0000000..3119f6a --- /dev/null +++ b/Sources/SyndiKit/Formats/Media/Podcast/PodcastSoundbite.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct PodcastSoundbite: Codable, Equatable { + public let startTime: TimeInterval + public let duration: TimeInterval + + public let title: String? + + enum CodingKeys: String, CodingKey { + case startTime + case duration + + case title = "" + } +} diff --git a/Sources/SyndiKit/Formats/Media/Podcast/PodcastTranscript+MimeType.swift b/Sources/SyndiKit/Formats/Media/Podcast/PodcastTranscript+MimeType.swift new file mode 100644 index 0000000..654c9d6 --- /dev/null +++ b/Sources/SyndiKit/Formats/Media/Podcast/PodcastTranscript+MimeType.swift @@ -0,0 +1,74 @@ +import Foundation + +extension PodcastTranscript { + private enum KnownMimeType: String, Codable { + case plain = "text/plain" + case html = "text/html" + case srt = "text/srt" + case vtt = "text/vtt" + case json = "application/json" + case subrip = "application/x-subrip" + + init?(caseInsensitive: String) { + self.init(rawValue: caseInsensitive) + } + + init?(mimeType: MimeType) { + switch mimeType { + case .plain: self = .plain + case .html: self = .html + case .srt: self = .srt + case .vtt: self = .vtt + case .json: self = .json + case .subrip: self = .subrip + case .unknown: return nil + } + } + } + + public enum MimeType: Codable, Equatable, RawRepresentable { + case plain + case html + case srt + case vtt + case json + case subrip + case unknown(String) + + public var rawValue: String { + if let knownMimeType = KnownMimeType(mimeType: self) { + return knownMimeType.rawValue + } else if case let .unknown(string) = self { + return string + } else { + fatalError( + // swiftlint:disable:next line_length + "Type attribute of with value: \(self) should either be a `KnownMimeType`, or unknown!" + ) + } + } + + public init?(rawValue: String) { + self.init(caseInsensitive: rawValue) + } + + public init(caseInsensitive: String) { + if let knownMimeType = KnownMimeType(caseInsensitive: caseInsensitive) { + self = .init(knownMimeType: knownMimeType) + } else { + self = .unknown(caseInsensitive) + } + } + + private init(knownMimeType: KnownMimeType) { + switch knownMimeType { + case .plain: self = .plain + case .html: self = .html + case .srt: self = .srt + case .vtt: self = .vtt + case .json: self = .json + case .subrip: self = .subrip + } + } + } +} diff --git a/Sources/SyndiKit/Formats/Media/Podcast/PodcastTranscript.swift b/Sources/SyndiKit/Formats/Media/Podcast/PodcastTranscript.swift new file mode 100644 index 0000000..bde41ac --- /dev/null +++ b/Sources/SyndiKit/Formats/Media/Podcast/PodcastTranscript.swift @@ -0,0 +1,19 @@ +import Foundation + +public struct PodcastTranscript: Codable, Equatable { + public enum Relationship: String, Codable { + case captions + } + + public let url: URL + public let type: MimeType + public let language: String? + public let rel: Relationship? + + enum CodingKeys: String, CodingKey { + case url + case type + case language + case rel + } +} diff --git a/Sources/SyndiKit/Formats/Media/Wordpress/WPCategory.swift b/Sources/SyndiKit/Formats/Media/Wordpress/WPCategory.swift index e36a0bd..3c395a8 100644 --- a/Sources/SyndiKit/Formats/Media/Wordpress/WPCategory.swift +++ b/Sources/SyndiKit/Formats/Media/Wordpress/WPCategory.swift @@ -13,7 +13,7 @@ public extension WordPressElements { case parent = "wp:categoryParent" case name = "wp:catName" } - + public init(termID: Int, niceName: CData, parent: CData, name: String) { self.termID = termID self.niceName = niceName @@ -24,10 +24,13 @@ public extension WordPressElements { } extension WordPressElements.Category: Equatable { - public static func == (lhs: WordPressElements.Category, rhs: WordPressElements.Category) -> Bool { + public static func == ( + lhs: WordPressElements.Category, + rhs: WordPressElements.Category + ) -> Bool { lhs.termID == rhs.termID - && lhs.niceName == rhs.niceName - && lhs.parent == rhs.parent - && lhs.name == rhs.name + && lhs.niceName == rhs.niceName + && lhs.parent == rhs.parent + && lhs.name == rhs.name } } diff --git a/Sources/SyndiKit/Formats/Media/Wordpress/WPPostMeta.swift b/Sources/SyndiKit/Formats/Media/Wordpress/WPPostMeta.swift index 80e34cd..fd56724 100644 --- a/Sources/SyndiKit/Formats/Media/Wordpress/WPPostMeta.swift +++ b/Sources/SyndiKit/Formats/Media/Wordpress/WPPostMeta.swift @@ -18,8 +18,11 @@ public extension WordPressElements { } extension WordPressElements.PostMeta: Equatable { - public static func == (lhs: WordPressElements.PostMeta, rhs: WordPressElements.PostMeta) -> Bool { + public static func == ( + lhs: WordPressElements.PostMeta, + rhs: WordPressElements.PostMeta + ) -> Bool { lhs.key == rhs.key - && lhs.value == rhs.value + && lhs.value == rhs.value } } diff --git a/Sources/SyndiKit/Formats/Media/Wordpress/WPTag.swift b/Sources/SyndiKit/Formats/Media/Wordpress/WPTag.swift index ffd5c85..85f574e 100644 --- a/Sources/SyndiKit/Formats/Media/Wordpress/WPTag.swift +++ b/Sources/SyndiKit/Formats/Media/Wordpress/WPTag.swift @@ -23,8 +23,7 @@ public extension WordPressElements { extension WordPressElements.Tag: Equatable { public static func == (lhs: WordPressElements.Tag, rhs: WordPressElements.Tag) -> Bool { lhs.termID == rhs.termID - && lhs.slug == rhs.slug - && lhs.name == rhs.name + && lhs.slug == rhs.slug + && lhs.name == rhs.name } } - diff --git a/Sources/SyndiKit/Formats/Media/Wordpress/WordPressPost.swift b/Sources/SyndiKit/Formats/Media/Wordpress/WordPressPost.swift index dd4fc19..10daded 100644 --- a/Sources/SyndiKit/Formats/Media/Wordpress/WordPressPost.swift +++ b/Sources/SyndiKit/Formats/Media/Wordpress/WordPressPost.swift @@ -153,7 +153,7 @@ public extension WordPressPost { let title = item.title let link = item.link let categoryTerms = item.categoryTerms - let meta = item.wpPostMeta ?? [] + let meta = item.wpPostMeta let pubDate = item.pubDate let categoryDictionary = Dictionary(grouping: categoryTerms, by: { diff --git a/Sources/SyndiKit/Formats/OPML/OPML.swift b/Sources/SyndiKit/Formats/OPML/OPML.swift new file mode 100644 index 0000000..debc827 --- /dev/null +++ b/Sources/SyndiKit/Formats/OPML/OPML.swift @@ -0,0 +1,14 @@ +import Foundation + +public struct OPML: Codable, Equatable { + public let version: String + + public let head: Head + public let body: Body + + enum CodingKeys: String, CodingKey { + case version + case head + case body + } +} diff --git a/Sources/SyndiKit/Formats/OPML/OPMLBody.swift b/Sources/SyndiKit/Formats/OPML/OPMLBody.swift new file mode 100644 index 0000000..1e405e0 --- /dev/null +++ b/Sources/SyndiKit/Formats/OPML/OPMLBody.swift @@ -0,0 +1,11 @@ +import Foundation + +public extension OPML { + struct Body: Codable, Equatable { + public let outlines: [Outline] + + enum CodingKeys: String, CodingKey { + case outlines = "outline" + } + } +} diff --git a/Sources/SyndiKit/Formats/OPML/OPMLHead.swift b/Sources/SyndiKit/Formats/OPML/OPMLHead.swift new file mode 100644 index 0000000..7d62c8f --- /dev/null +++ b/Sources/SyndiKit/Formats/OPML/OPMLHead.swift @@ -0,0 +1,35 @@ +import Foundation + +public extension OPML { + struct Head: Codable, Equatable { + public let title: String? + public let dateCreated: String? + public let dateModified: String? + public let ownerName: String? + public let ownerEmail: String? + public let ownerId: String? + public let docs: String? + public let expansionStates: ListString? + public let vertScrollState: Int? + public let windowTop: Int? + public let windowLeft: Int? + public let windowBottom: Int? + public let windowRight: Int? + + enum CodingKeys: String, CodingKey { + case title + case dateCreated + case dateModified + case ownerName + case ownerEmail + case ownerId + case docs + case expansionStates = "expansionState" + case vertScrollState + case windowTop + case windowLeft + case windowBottom + case windowRight + } + } +} diff --git a/Sources/SyndiKit/Formats/OPML/OPMLOutline.swift b/Sources/SyndiKit/Formats/OPML/OPMLOutline.swift new file mode 100644 index 0000000..ec098d5 --- /dev/null +++ b/Sources/SyndiKit/Formats/OPML/OPMLOutline.swift @@ -0,0 +1,39 @@ +import Foundation + +public extension OPML { + struct Outline: Codable, Equatable { + public let text: String + public let title: String? + public let description: String? + public let type: OutlineType? + public let url: URL? + public let htmlUrl: URL? + public let xmlUrl: URL? + public let language: String? + public let created: String? + public let categories: ListString? + public let isComment: Bool? + public let isBreakpoint: Bool? + public let version: String? + + public let outlines: [Outline]? + + enum CodingKeys: String, CodingKey { + case text + case title + case description + case type + case url + case htmlUrl + case xmlUrl + case language + case created + case categories = "category" + case isComment + case isBreakpoint + case version + + case outlines = "outline" + } + } +} diff --git a/Sources/SyndiKit/Formats/OPML/OPMLOutlineType.swift b/Sources/SyndiKit/Formats/OPML/OPMLOutlineType.swift new file mode 100644 index 0000000..3400edb --- /dev/null +++ b/Sources/SyndiKit/Formats/OPML/OPMLOutlineType.swift @@ -0,0 +1,7 @@ +import Foundation + +public enum OutlineType: String, Codable { + case rss + case link + case include +} diff --git a/Sources/SyndiKit/Substring.SubSequence.swift b/Sources/SyndiKit/Substring.SubSequence.swift new file mode 100644 index 0000000..38eafa7 --- /dev/null +++ b/Sources/SyndiKit/Substring.SubSequence.swift @@ -0,0 +1,19 @@ +import Foundation + +extension Substring.SubSequence { + func asDouble() -> Double? { + Double(self) + } + + func asInt() -> Int? { + guard let double = Double(self) else { return nil } + + return Int(double) + } + + func asExactInt() -> Int? { + guard let double = Double(self) else { return nil } + + return Int(exactly: double) + } +} diff --git a/Sources/SyndiKit/SyndiKit.docc/SyndiKit.md b/Sources/SyndiKit/SyndiKit.docc/SyndiKit.md index c0f4d3b..e2c3281 100644 --- a/Sources/SyndiKit/SyndiKit.docc/SyndiKit.md +++ b/Sources/SyndiKit/SyndiKit.docc/SyndiKit.md @@ -182,13 +182,13 @@ Abstract media types which can be pulled for the various ``Entryable`` objects. - ``MediaContent`` - ``Video`` - ### XML Primitive Types In many cases, types are encoded in non-matching types but are intended to strong-typed for various formats. These primitives are setup to make XML decoding easier while retaining their intended strong-type. - ``CData`` - ``XMLStringInt`` +- ``ListString`` ### Syndication Updates @@ -206,6 +206,7 @@ Specific properties related to the Atom format. - ``AtomEntry`` - ``AtomCategory`` - ``AtomMedia`` +- ``AtomMediaGroup`` - ``Link`` ### JSON Feed Format @@ -215,6 +216,11 @@ Specific properties related to the JSON Feed format. - ``JSONFeed`` - ``JSONItem`` +### OPML Feed Formate + +- ``OPML`` +- ``OutlineType`` + ### RSS Feed Format Specific properties related to the RSS Feed format. @@ -226,6 +232,19 @@ Specific properties related to the RSS Feed format. - ``RSSItemCategory`` - ``Enclosure`` +### Podcast Extensions + +Specific properties related to [podcasts](https://github.com/Podcastindex-org/podcast-namespace). + +- ``PodcastPerson`` +- ``PodcastSeason`` +- ``PodcastChapters`` +- ``PodcastLocation`` +- ``PodcastSoundbite`` +- ``PodcastTranscript`` +- ``PodcastFunding`` +- ``PodcastLocked`` + ### WordPress Extensions Specific extension properties provided by WordPress. diff --git a/Tests/SyndiKitTests/Content.Directories.swift b/Tests/SyndiKitTests/Content.Directories.swift index 124760e..24fd7eb 100644 --- a/Tests/SyndiKitTests/Content.Directories.swift +++ b/Tests/SyndiKitTests/Content.Directories.swift @@ -1,7 +1,7 @@ import Foundation @testable import SyndiKit -extension Content { +internal extension Content { enum Directories { static let data = URL(fileURLWithPath: #file) .deletingLastPathComponent() @@ -10,6 +10,7 @@ extension Content { .appendingPathComponent("Data") static let XML = data.appendingPathComponent("XML") static let JSON = data.appendingPathComponent("JSON") + static let OPML = data.appendingPathComponent("OPML") static let WordPress = data.appendingPathComponent("WordPress") } } diff --git a/Tests/SyndiKitTests/Content.ResultDictionary.swift b/Tests/SyndiKitTests/Content.ResultDictionary.swift index 9e465d9..8232287 100644 --- a/Tests/SyndiKitTests/Content.ResultDictionary.swift +++ b/Tests/SyndiKitTests/Content.ResultDictionary.swift @@ -1,5 +1,6 @@ import Foundation @testable import SyndiKit +import XMLCoder enum Content { typealias ResultDictionary = [String: Result] @@ -17,12 +18,30 @@ enum Content { }.map(Dictionary.init(uniqueKeysWithValues:)).get() } - static let decoder = SynDecoder() + static let synDecoder = SynDecoder() + static let xmlDecoder = XMLDecoder() - // swiftlint:disable force_try line_length - static let xmlFeeds = try! Content.resultDictionaryFrom(directoryURL: Directories.XML, by: Self.decoder.decode(_:)) - static let jsonFeeds = try! Content.resultDictionaryFrom(directoryURL: Directories.JSON, by: Self.decoder.decode(_:)) - static let wordpressDataSet = try! FileManager.default.dataFromDirectory(at: Directories.WordPress) + static let xmlFeeds = try! Content.resultDictionaryFrom( + directoryURL: Directories.XML, + by: Self.synDecoder.decode(_:) + ) + static let jsonFeeds = try! Content.resultDictionaryFrom( + directoryURL: Directories.JSON, + by: Self.synDecoder.decode(_:) + ) + static let opml = try! Content.resultDictionaryFrom( + directoryURL: Directories.OPML, + by: Self.xmlDecoder.decodeOPML(_:) + ) + static let wordpressDataSet = try! FileManager.default.dataFromDirectory( + at: Directories.WordPress + ) static let blogs: SiteCollection = try! .init(contentsOf: Directories.data.appendingPathComponent("blogs.json")) // swiftlint:enable force_try line_length } + +extension XMLDecoder { + func decodeOPML(_ data: Data) throws -> OPML { + try decode(OPML.self, from: data) + } +} diff --git a/Tests/SyndiKitTests/Extensions/FileManager.swift b/Tests/SyndiKitTests/Extensions/FileManager.swift index 903ea5e..7e4951a 100644 --- a/Tests/SyndiKitTests/Extensions/FileManager.swift +++ b/Tests/SyndiKitTests/Extensions/FileManager.swift @@ -1,6 +1,6 @@ import Foundation -extension FileManager { +internal extension FileManager { func dataFromDirectory(at sourceURL: URL) throws -> [(String, Result)] { let urls = try contentsOfDirectory( at: sourceURL, @@ -8,10 +8,7 @@ extension FileManager { options: [] ) - return urls.mapPairResult { - try Data(contentsOf: $0) - }.map { - ($0.0.deletingPathExtension().lastPathComponent, $0.1) - } + return urls.mapPairResult { try Data(contentsOf: $0) } + .map { ($0.0.deletingPathExtension().lastPathComponent, $0.1) } } } diff --git a/Tests/SyndiKitTests/Extensions/Sequence.swift b/Tests/SyndiKitTests/Extensions/Sequence.swift index f81f980..85950ba 100644 --- a/Tests/SyndiKitTests/Extensions/Sequence.swift +++ b/Tests/SyndiKitTests/Extensions/Sequence.swift @@ -1,4 +1,4 @@ -extension Sequence { +internal extension Sequence { func mapPairResult( _ transform: @escaping (Element) throws -> Success ) -> [(Element, Result)] { diff --git a/Tests/SyndiKitTests/Extensions/SiteCollection.swift b/Tests/SyndiKitTests/Extensions/SiteCollection.swift index 4e1ed0a..2fde8e3 100644 --- a/Tests/SyndiKitTests/Extensions/SiteCollection.swift +++ b/Tests/SyndiKitTests/Extensions/SiteCollection.swift @@ -1,7 +1,7 @@ import Foundation @testable import SyndiKit -extension SiteCollection { +internal extension SiteCollection { init(contentsOf url: URL, using decoder: JSONDecoder = .init()) throws { let data = try Data(contentsOf: url) self = try decoder.decode(SiteCollection.self, from: data) diff --git a/Tests/SyndiKitTests/Extensions/String.swift b/Tests/SyndiKitTests/Extensions/String.swift index 7072bc6..6049af4 100644 --- a/Tests/SyndiKitTests/Extensions/String.swift +++ b/Tests/SyndiKitTests/Extensions/String.swift @@ -1,4 +1,4 @@ -extension String { +internal extension String { func trimAndNilIfEmpty() -> String? { let text = trimmingCharacters(in: .whitespacesAndNewlines) return text.isEmpty ? nil : text diff --git a/Tests/SyndiKitTests/OPMLTests.swift b/Tests/SyndiKitTests/OPMLTests.swift new file mode 100644 index 0000000..22da4ab --- /dev/null +++ b/Tests/SyndiKitTests/OPMLTests.swift @@ -0,0 +1,125 @@ +@testable import SyndiKit +import XCTest + +internal final class OPMLTests: XCTestCase { + internal func testSubscriptionList() throws { + let opml = try Content.opml["subscriptionList"]?.get() + + XCTAssertEqual(opml?.head.title, "mySubscriptions.opml") + XCTAssertEqual(opml?.head.ownerEmail, "dave@scripting.com") + XCTAssertEqual(opml?.body.outlines.count, 13) + + let firstOutline = opml?.body.outlines.first + + XCTAssertEqual(firstOutline?.text, "CNET News.com") + XCTAssertEqual(firstOutline?.description, "Tech news and business reports by CNET News.com. Focused on information technology, core topics include computers, hardware, software, networking, and Internet media.") + XCTAssertEqual(firstOutline?.htmlUrl, URL(string: "http://news.com.com/")!) + XCTAssertEqual(firstOutline?.language, "unknown") + XCTAssertEqual(firstOutline?.title, "CNET News.com") + XCTAssertEqual(firstOutline?.type, .rss) + XCTAssertEqual(firstOutline?.version, "RSS2") + XCTAssertEqual(firstOutline?.xmlUrl, URL(string: "http://news.com.com/2547-1_3-0-5.xml")!) + } + + internal func testStates() throws { + let opml = try Content.opml["states"]?.get() + + XCTAssertEqual(opml?.head.title, "states.opml") + XCTAssertEqual(opml?.head.ownerEmail, "dave@scripting.com") + XCTAssertEqual(opml?.body.outlines.count, 1) + + let usOutline = opml?.body.outlines.first + + XCTAssertEqual(usOutline?.text, "United States") + + XCTAssertEqual(usOutline?.outlines?.count, 8) + + let farWestOutline = usOutline?.outlines?.first + + XCTAssertEqual(farWestOutline?.text, "Far West") + XCTAssertEqual(farWestOutline?.outlines?.count, 6) + + let nevadaOutline = farWestOutline?.outlines?[3] + XCTAssertEqual(nevadaOutline?.outlines?.count, 4) + } + + internal func testCategory() throws { + let opml = try Content.opml["category"]?.get() + + XCTAssertEqual(opml?.head.title, "Illustrating the category attribute") + XCTAssertEqual(opml?.body.outlines.count, 1) + + let outline = opml?.body.outlines.first + + XCTAssertEqual(outline?.text, "The Mets are the best team in baseball.") + XCTAssertEqual(outline?.categories?.values.count, 2) + XCTAssertEqual(outline?.categories?.values[0], "/Philosophy/Baseball/Mets") + XCTAssertEqual(outline?.categories?.values[1], "/Tourism/New York") + } + + internal func testPlacesLived() throws { + let opml = try Content.opml["placesLived"]?.get() + + XCTAssertEqual(opml?.head.title, "placesLived.opml") + XCTAssertEqual(opml?.head.ownerId, "http://www.opml.org/profiles/sendMail?usernum=1") + XCTAssertEqual(opml?.head.expansionStates?.values.count, 6) + XCTAssertEqual(opml?.head.expansionStates?.values[0], 1) + XCTAssertEqual(opml?.head.expansionStates?.values[3], 10) + } + + internal func testSimpleScript() throws { + let opml = try Content.opml["simpleScript"]?.get() + + XCTAssertEqual(opml?.head.title, "workspace.userlandsamples.doSomeUpstreaming") + XCTAssertEqual(opml?.head.expansionStates?.values.count, 3) + XCTAssertEqual(opml?.head.expansionStates?.values[0], 1) + XCTAssertEqual(opml?.head.expansionStates?.values[2], 4) + + XCTAssertEqual(opml?.body.outlines.count, 4) + + let isCommentOutline = opml?.body.outlines.first + XCTAssertEqual(isCommentOutline?.text, "Changes") + XCTAssertEqual(isCommentOutline?.isComment, true) + + let isBreakpointOutline = opml?.body.outlines[1].outlines?.first + XCTAssertEqual(isBreakpointOutline?.text, "file.surefilepath (f)") + XCTAssertEqual(isBreakpointOutline?.isBreakpoint, true) + } + + internal func testInvalidExpansionStateType() throws { + XCTAssertThrowsError(try Content.opml["category_invalidExpansionState"]?.get()) { error in + guard case let .typeMismatch(type, context) = error as? DecodingError else { + XCTFail("Expected typeMismatch error.") + return + } + + XCTAssertTrue(type is Int.Type) + XCTAssertEqual( + context.debugDescription, + "Invalid value: one" + ) + } + } + + internal func testType() throws { + var opml = try Content.opml["subscriptionList"]?.get() + + XCTAssertEqual(opml?.body.outlines.first?.type, .rss) + XCTAssertNotNil(opml?.body.outlines.first?.text) + XCTAssertNotNil(opml?.body.outlines.first?.xmlUrl) + + opml = try Content.opml["directory"]?.get() + + XCTAssertEqual(opml?.body.outlines.first?.type, .link) + XCTAssertNotNil(opml?.body.outlines.first?.url) + + opml = try Content.opml["placesLived"]?.get() + let floridaOutline = opml?.body.outlines.first? + .outlines?.first( + where: { $0.text == "Florida" } + ) + + XCTAssertEqual(floridaOutline?.type, .include) + XCTAssertNotNil(floridaOutline?.url) + } +} diff --git a/Tests/SyndiKitTests/RSSCoded.Durations.swift b/Tests/SyndiKitTests/RSSCoded.Durations.swift index add5ad2..5dd1f0e 100644 --- a/Tests/SyndiKitTests/RSSCoded.Durations.swift +++ b/Tests/SyndiKitTests/RSSCoded.Durations.swift @@ -1,6 +1,6 @@ import Foundation -extension SyndiKitTests { +internal extension SyndiKitTests { static let durationSets: [String: [TimeInterval]] = [ "empowerapps-show": [ 2_746, diff --git a/Tests/SyndiKitTests/RSSCodedTests.swift b/Tests/SyndiKitTests/RSSCodedTests.swift index 0faafe5..56f9dcc 100644 --- a/Tests/SyndiKitTests/RSSCodedTests.swift +++ b/Tests/SyndiKitTests/RSSCodedTests.swift @@ -176,6 +176,291 @@ public final class SyndiKitTests: XCTestCase { } } + func testChannelPodcastElements() { + guard let feed = try? Content.xmlFeeds["empowerapps-show-cdata_summary"]?.get() else { + XCTFail("Missing Podcast \(name)") + return + } + + guard let rss = feed as? RSSFeed else { + XCTFail("Wrong Type \(name)") + return + } + + XCTAssertEqual(rss.channel.podcastLocked?.owner, "leogdion@brightdigit.com") + XCTAssertEqual(rss.channel.podcastLocked?.isLocked, false) + + XCTAssertEqual(rss.channel.podcastFundings.count, 1) + + let funding = rss.channel.podcastFundings[0] + XCTAssertEqual(funding.description, "Support this podcast on Patreon") + XCTAssertEqual(funding.url, URL(strict: "https://www.patreon.com/empowerappsshow")) + + XCTAssertEqual(rss.channel.podcastPeople.count, 1) + + let person = rss.channel.podcastPeople[0] + XCTAssertEqual(person.fullname, "Leo Dion") + XCTAssertEqual(person.role, .host) + XCTAssertEqual(person.href, URL(strict: "https://brightdigit.com")) + XCTAssertEqual(person.img, URL(strict: "https://images.transistor.fm/file/transistor/images/person/401f05b8-f63f-4b96-803f-c7ac9233b459/1664979700-image.jpg")) + } + + func testItemPodcastElements() { + guard let feed = try? Content.xmlFeeds["empowerapps-show-cdata_summary"]?.get() else { + XCTFail("Missing Podcast \(name)") + return + } + + guard let rss = feed as? RSSFeed else { + XCTFail("Wrong Type \(name)") + return + } + + guard let item = rss.channel.items.first else { + XCTFail("Missing Item \(name)") + return + } + + let host = item.podcastPeople[0] + XCTAssertEqual(host.fullname, "Leo Dion") + XCTAssertEqual(host.role, .host) + XCTAssertEqual(host.href, URL(strict: "https://brightdigit.com")) + XCTAssertEqual(host.img, URL(strict: "https://images.transistor.fm/file/transistor/images/person/401f05b8-f63f-4b96-803f-c7ac9233b459/1664979700-image.jpg")) + + let guest = item.podcastPeople[1] + XCTAssertEqual(guest.fullname, "CompileSwift") + XCTAssertEqual(guest.role, .guest) + XCTAssertEqual(guest.href, URL(strict: "https://compileswift.com")) + XCTAssertEqual(guest.img, URL(strict: "https://images.transistor.fm/file/transistor/images/person/e36ebf22-69fa-4e4f-a79b-1348c4d39267/1668262451-image.jpg")) + + XCTAssertEqual(item.podcastTranscripts.count, 1) + + let transcript = item.podcastTranscripts[0] + XCTAssertEqual(transcript.url, URL(strict: "https://share.transistor.fm/s/336118a1/transcript.srt")!) + XCTAssertEqual(transcript.type, .srt) + XCTAssertEqual(transcript.rel, .captions) + + let chapters = item.podcastChapters + XCTAssertEqual(chapters?.url, URL(strict: "https://share.transistor.fm/s/336118a1/chapters.json")!) + XCTAssertEqual(chapters?.type, .json) + } + + func testPodcastPeopleUnknownRole() throws { + let expectedRole = "worker" + let xmlStr = """ + Alice Brown + + """ + + guard let data = xmlStr.data(using: .utf8) else { + XCTFail("Expected data out of \(xmlStr)") + return + } + + let sut = try XMLDecoder().decode(PodcastPerson.self, from: data) + + XCTAssertEqual(sut.role, .unknown(expectedRole)) + } + + func testPodcastPeopleMissingRole() throws { + let xmlStr = """ + Alice Brown + + """ + + guard let data = xmlStr.data(using: .utf8) else { + XCTFail("Expected data out of \(xmlStr)") + return + } + + let sut = try XMLDecoder().decode(PodcastPerson.self, from: data) + + XCTAssertNil(sut.role) + } + + func testPodcastChaptersUnknownMimeType() throws { + let expectedType = "yaml" + let xmlStr = """ + + """ + + guard let data = xmlStr.data(using: .utf8) else { + XCTFail("Expected data out of \(xmlStr)") + return + } + + let sut = try XMLDecoder().decode(PodcastChapters.self, from: data) + + XCTAssertEqual(sut.type, .unknown(expectedType)) + } + + func testPodcastLocationOfTypeRelation() throws { + let expectedLatitude = 30.267_2 + let expectedLongitude = 97.743_1 + let expectedOsmType = "R" + let expectedOsmID = 113_314 + let xmlStr = """ + + Austin, TX + + """ + + guard let data = xmlStr.data(using: .utf8) else { + XCTFail("Expected data out of \(xmlStr)") + return + } + + let sut = try XMLDecoder().decode(PodcastLocation.self, from: data) + + XCTAssertEqual(sut.geo?.latitude, expectedLatitude) + XCTAssertEqual(sut.geo?.longitude, expectedLongitude) + XCTAssertEqual(sut.osmQuery?.id, expectedOsmID) + XCTAssertEqual(sut.osmQuery?.type, .relation) + XCTAssertNil(sut.osmQuery?.revision) + } + + func testPodcastLocationWithAccuracy() throws { + let expectedLatitude = 30.267_2 + let expectedLongitude = 97.743_1 + let expectedAccuracy = 350.0 + let xmlStr = """ + + Austin, TX + + """ + + guard let data = xmlStr.data(using: .utf8) else { + XCTFail("Expected data out of \(xmlStr)") + return + } + + let sut = try XMLDecoder().decode(PodcastLocation.self, from: data) + + XCTAssertEqual(sut.geo?.latitude, expectedLatitude) + XCTAssertEqual(sut.geo?.longitude, expectedLongitude) + XCTAssertEqual(sut.geo?.accuracy, expectedAccuracy) + XCTAssertEqual(sut.geo?.description, "geo:\(expectedLatitude),\(expectedLongitude);u=\(expectedAccuracy)") + XCTAssertNil(sut.geo?.altitude) + } + + func testPodcastLocationWithAltitude() throws { + let expectedLatitude = 30.267_2 + let expectedLongitude = 97.743_1 + let expectedAltitude = 250.0 + let xmlStr = """ + + Austin, TX + + """ + + guard let data = xmlStr.data(using: .utf8) else { + XCTFail("Expected data out of \(xmlStr)") + return + } + + let sut = try XMLDecoder().decode(PodcastLocation.self, from: data) + + XCTAssertEqual(sut.geo?.latitude, expectedLatitude) + XCTAssertEqual(sut.geo?.longitude, expectedLongitude) + XCTAssertEqual(sut.geo?.altitude, expectedAltitude) + XCTAssertEqual(sut.geo?.description, "geo:\(expectedLatitude),\(expectedLongitude),\(expectedAltitude)") + XCTAssertNil(sut.geo?.accuracy) + } + + func testPodcastLocationWithInvalidGeoData() throws { + let missingGeoScheme = """ + Austin, TX + """ + + try assertInvalidGeoData(from: missingGeoScheme) + + let missingCoords = """ + Austin, TX + """ + try assertInvalidGeoData(from: missingCoords) + + let invalidCoords = """ + Austin, TX + """ + try assertInvalidGeoData(from: invalidCoords) + } + + private func assertInvalidGeoData(from xmlStr: String) throws { + guard let data = xmlStr.data(using: .utf8) else { + XCTFail("Expected data out of \(xmlStr)") + return + } + + XCTAssertThrowsError( + try XMLDecoder().decode(PodcastLocation.self, from: data) + ) { error in + assertPodcastLocationDecodingError(error, codingKey: .geo) + } + } + + func testPodcastLocationWithInvalidOsmData() throws { + let invalidOsmType = """ + Austin, TX + """ + + try assertInvalidOsmData(from: invalidOsmType) + + let invalidOsmID = """ + Austin, TX + """ + + try assertInvalidOsmData(from: invalidOsmID) + } + + private func assertInvalidOsmData(from xmlStr: String) throws { + guard let data = xmlStr.data(using: .utf8) else { + XCTFail("Expected data out of \(xmlStr)") + return + } + + XCTAssertThrowsError( + try XMLDecoder().decode(PodcastLocation.self, from: data) + ) { error in + assertPodcastLocationDecodingError(error, codingKey: .osmQuery) + } + } + + private func assertPodcastLocationDecodingError(_ error: Error, codingKey: PodcastLocation.CodingKeys) { + guard + let decodingError = error as? DecodingError, + case let DecodingError.dataCorrupted(context) = decodingError else { + XCTFail() + return + } + + XCTAssertTrue( + context.codingPath.contains( + where: { $0.stringValue == codingKey.rawValue } + ) + ) + } + func testPodcastEpisodes() { let missingEpisodes = ["it-guy": [76, 56, 45]] let podcasts = [ @@ -255,12 +540,12 @@ public final class SyndiKitTests: XCTestCase { let itemTitle = "My Taylor Deep Dish Swift Heroes World Tour" - guard let item = rss.channel.items.first(where: { $0.title == itemTitle } ) else { + guard let item = rss.channel.items.first(where: { $0.title == itemTitle }) else { XCTFail("Expected to find episode of title: \(itemTitle)") return } - XCTAssertNil(item.podcastPerson) + XCTAssertTrue(item.podcastPeople.isEmpty) } func testEpisodesWithHostAndGuestPersons() { @@ -282,22 +567,22 @@ public final class SyndiKitTests: XCTestCase { XCTAssertFalse(items.isEmpty) for item in items { - let host = item.podcastPerson?.first(where: { $0.role.lowercased() == "host" }) + let host = item.podcastPeople.first(where: { $0.role == .host }) XCTAssertNotNil(host) - XCTAssertEqual(host?.name, "Leo Dion") - XCTAssertEqual(host?.href, "https://brightdigit.com") + XCTAssertEqual(host?.fullname, "Leo Dion") + XCTAssertEqual(host?.href, URL(strict: "https://brightdigit.com")) XCTAssertEqual( host?.img, URL(string: "https://images.transistor.fm/file/transistor/images/person/401f05b8-f63f-4b96-803f-c7ac9233b459/1664979700-image.jpg") ) // Both podcasts have the same guest - let guest = item.podcastPerson?.first(where: { $0.role.lowercased() == "guest" }) + let guest = item.podcastPeople.first(where: { $0.role == .guest }) XCTAssertNotNil(guest) - XCTAssertEqual(guest?.name, "CompileSwift") - XCTAssertEqual(guest?.href, "https://compileswift.com") + XCTAssertEqual(guest?.fullname, "CompileSwift") + XCTAssertEqual(guest?.href, URL(strict: "https://compileswift.com")) XCTAssertEqual( guest?.img, URL(string: "https://images.transistor.fm/file/transistor/images/person/e36ebf22-69fa-4e4f-a79b-1348c4d39267/1668262451-image.jpg") diff --git a/Tests/SyndiKitTests/RSSItemCategoryTests.swift b/Tests/SyndiKitTests/RSSItemCategoryTests.swift index 94d4f70..50482dd 100644 --- a/Tests/SyndiKitTests/RSSItemCategoryTests.swift +++ b/Tests/SyndiKitTests/RSSItemCategoryTests.swift @@ -1,8 +1,7 @@ -import XCTest @testable import SyndiKit +import XCTest final class RSSItemCategoryTests: XCTestCase { - func testTwoEqualCategories() { let c1 = RSSItemCategory( value: "Top Menu", diff --git a/Tests/SyndiKitTests/UTF8EncodedURLTests.swift b/Tests/SyndiKitTests/UTF8EncodedURLTests.swift new file mode 100644 index 0000000..567b887 --- /dev/null +++ b/Tests/SyndiKitTests/UTF8EncodedURLTests.swift @@ -0,0 +1,22 @@ +@testable import SyndiKit +import XCTest +import XMLCoder + +internal final class UTF8EncodedURLTests: XCTestCase { + internal func testDecode() throws { + let expectedURL = URL(strict: "http://www.example.com/index.php")! + let urlStr = """ + "\(expectedURL)" + """ + + guard let data = urlStr.data(using: .utf8) else { + XCTFail("Expected data out of \(urlStr)") + return + } + + let sut = try JSONDecoder().decode(UTF8EncodedURL.self, from: data) + + XCTAssertEqual(sut.value, expectedURL) + XCTAssertNil(sut.string) + } +} diff --git a/Tests/SyndiKitTests/WordPressElementsTests.swift b/Tests/SyndiKitTests/WordPressElementsTests.swift index 624607c..4b07a07 100644 --- a/Tests/SyndiKitTests/WordPressElementsTests.swift +++ b/Tests/SyndiKitTests/WordPressElementsTests.swift @@ -1,8 +1,7 @@ -import XCTest @testable import SyndiKit +import XCTest final class WordPressElementsTests: XCTestCase { - func testCategoryEquatable() { let c1 = WordPressElements.Category( termID: 1, @@ -50,5 +49,4 @@ final class WordPressElementsTests: XCTestCase { XCTAssertNotEqual(pm1, pm2) } - } diff --git a/Tests/SyndiKitTests/WordpressTests.swift b/Tests/SyndiKitTests/WordpressTests.swift index 55ce6a7..3682da0 100644 --- a/Tests/SyndiKitTests/WordpressTests.swift +++ b/Tests/SyndiKitTests/WordpressTests.swift @@ -3,18 +3,16 @@ import Foundation import XCTest final class WordpressTests: XCTestCase { - - static let baseSiteURLs : [String : URL] = [ - "articles" : URL(string: "https://brightdigit.com/")!, - "tutorials" : URL(string: "https://brightdigit.com/")! + static let baseSiteURLs: [String: URL] = [ + "articles": URL(string: "https://brightdigit.com/")!, + "tutorials": URL(string: "https://brightdigit.com/")! ] - - - static let baseBlogURLs : [String : URL] = [ - "articles" : URL(string: "https://brightdigit.com")!, - "tutorials" : URL(string: "https://learningswift.brightdigit.com")! + + static let baseBlogURLs: [String: URL] = [ + "articles": URL(string: "https://brightdigit.com")!, + "tutorials": URL(string: "https://learningswift.brightdigit.com")! ] - + func testDateDecoder() { let dateDecoder = DateFormatterDecoder.RSS.decoder let result = dateDecoder.decodeString("Fri, 06 Oct 2017 17:21:35 +0000") diff --git a/Tests/SyndiKitTests/XMLStringIntTests.swift b/Tests/SyndiKitTests/XMLStringIntTests.swift new file mode 100644 index 0000000..64a4a8a --- /dev/null +++ b/Tests/SyndiKitTests/XMLStringIntTests.swift @@ -0,0 +1,36 @@ +@testable import SyndiKit +import XCTest +import XMLCoder + +internal final class XMLStringIntTests: XCTestCase { + internal func testDecodeValidXMLValue() throws { + let expectedAge = 10 + let xmlStr = """ + \(expectedAge) + """ + + guard let data = xmlStr.data(using: .utf8) else { + XCTFail("Expected data out of \(xmlStr)") + return + } + + let sut = try XMLDecoder().decode(XMLStringInt.self, from: data) + + XCTAssertEqual(sut.value, expectedAge) + } + + internal func testDecodeInvalidXMLValue() throws { + let xmlStr = """ + invalid + """ + + guard let data = xmlStr.data(using: .utf8) else { + XCTFail("Expected data out of \(xmlStr)") + return + } + + XCTAssertThrowsError(try XMLDecoder().decode(XMLStringInt.self, from: data)) { error in + XCTAssertNotNil(error as? DecodingError) + } + } +} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..39c657e --- /dev/null +++ b/project.yml @@ -0,0 +1,13 @@ +name: SyndiKit +settings: + LINT_MODE: ${LINT_MODE} +packages: + SyndiKit: + path: . +aggregateTargets: + Lint: + buildScripts: + - path: Scripts/lint.sh + name: Lint + basedOnDependencyAnalysis: false + schemes: {} \ No newline at end of file