├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ ├── codeql.yml │ └── syndikit.yml ├── .gitignore ├── .hound.yml ├── .htaccess ├── .periphery.yml ├── .spi.yml ├── .swift-version ├── .swiftformat ├── .swiftlint.yml ├── Assets └── logo.svg ├── Data ├── JSON │ ├── advancedswift.json │ ├── andyibanez.json │ ├── appfigures.youtube.json │ ├── apple.developer.json │ ├── apple.releases.json │ ├── atomicbird.json │ ├── avanderlee.json │ ├── cnn_latest.json │ ├── cocoacasts.json │ ├── donnywals.json │ ├── empowerapps-show.json │ ├── enekoalonso.json │ ├── fivestars.json │ ├── ideveloper.json │ ├── ios-goodies.json │ ├── iosdevweekly.json │ ├── it-guy.json │ ├── kilo.youtube.json │ ├── mecid.json │ ├── mjtsai.json │ ├── mokacoding.json │ ├── radar.json │ ├── raywenderlich.json │ ├── revenuecat.json │ ├── rhonabwy.json │ ├── senestenyt.json │ ├── stewart.youtube.json │ ├── swiftbysundell.json │ ├── swiftpackageindex.json │ ├── swiftweeklybrief.json │ ├── timac.json │ ├── tundsdev.youtube.json │ ├── vincent.youtube.json │ └── wwdcnotes.json ├── OPML │ ├── category.opml │ ├── category_invalidExpansionState.opml │ ├── development.opml │ ├── directory.opml │ ├── placesLived.opml │ ├── simpleScript.opml │ ├── states.opml │ └── subscriptionList.opml ├── WordPress │ ├── articles.xml │ └── tutorials.xml ├── XML │ ├── advancedswift.xml │ ├── andyibanez.xml │ ├── appfigures.youtube.xml │ ├── apple.developer.xml │ ├── apple.releases.xml │ ├── atomicbird.xml │ ├── avanderlee.xml │ ├── cnn_latest.xml │ ├── cocoacasts.xml │ ├── donnywals.xml │ ├── empowerapps-show-cdata_summary.xml │ ├── empowerapps-show.xml │ ├── enekoalonso.xml │ ├── fivestars.xml │ ├── ideveloper.xml │ ├── ios-goodies.xml │ ├── iosdevweekly.xml │ ├── it-guy.xml │ ├── kilo.youtube.xml │ ├── mecid.xml │ ├── mjtsai.xml │ ├── mokacoding.xml │ ├── news.rss │ ├── radar.xml │ ├── raywenderlich.xml │ ├── revenuecat.xml │ ├── rhonabwy.xml │ ├── senestenyt.xml │ ├── stewart.youtube.xml │ ├── swiftbysundell.xml │ ├── swiftpackageindex.xml │ ├── swiftweeklybrief.xml │ ├── timac.xml │ ├── tundsdev.youtube.xml │ ├── vincent.youtube.xml │ ├── wait-wait-dont-tell-me.xml │ └── wwdcnotes.xml ├── blogs.json └── urls.tsv ├── Dockerfile ├── LICENSE ├── Mintfile ├── Package.resolved ├── Package.swift ├── README.md ├── Scripts ├── build_test_data.sh ├── docc.sh ├── gh-md-toc ├── httpd.conf └── lint.sh ├── Sources └── SyndiKit │ ├── Character.swift │ ├── Collection.swift │ ├── Common │ ├── Author.swift │ ├── EntryCategory.swift │ ├── Entryable.swift │ ├── Feedable.swift │ ├── Link.swift │ └── Primitives │ │ ├── CData.swift │ │ ├── ListString.swift │ │ ├── UTF8EncodedURL.swift │ │ └── XMLStringInt.swift │ ├── Decoding │ ├── AnyDecoding.swift │ ├── CustomDecoderSetup.swift │ ├── DateFormatterDecoder.swift │ ├── DecodableFeed.swift │ ├── DecoderSetup.swift │ ├── DecoderSource.swift │ ├── Decoding.swift │ ├── DecodingError.swift │ ├── SynDecoder.swift │ └── TypeDecoder.swift │ ├── Dictionary.swift │ ├── Formats │ ├── Blogs │ │ ├── CategoryDescriptor.swift │ │ ├── CategoryLanguage.swift │ │ ├── Site.swift │ │ ├── SiteCategory.swift │ │ ├── SiteCategoryType.swift │ │ ├── SiteCollection.swift │ │ ├── SiteDirectory.swift │ │ ├── SiteDirectoryBuilder.swift │ │ ├── SiteLanguage.swift │ │ ├── SiteLanguageCategory+Site.swift │ │ ├── SiteLanguageCategory.swift │ │ ├── SiteLanguageContent.swift │ │ └── SiteLanguageType.swift │ ├── Feeds │ │ ├── Atom │ │ │ ├── AtomCategory.swift │ │ │ ├── AtomEntry.swift │ │ │ ├── AtomFeed.swift │ │ │ ├── AtomMedia.swift │ │ │ └── AtomMediaGroup.swift │ │ ├── JSONFeed │ │ │ ├── JSONFeed.swift │ │ │ └── JSONItem.swift │ │ └── RSS │ │ │ ├── Enclosure.swift │ │ │ ├── EntryID.swift │ │ │ ├── RSSChannel.swift │ │ │ ├── RSSFeed.swift │ │ │ ├── RSSImage.swift │ │ │ ├── RSSItem+Decodings.swift │ │ │ ├── RSSItem+Init.swift │ │ │ ├── RSSItem.swift │ │ │ └── RSSItemCategory.swift │ ├── Media │ │ ├── MediaContent.swift │ │ ├── Podcast │ │ │ ├── PodcastChapters+MimeType.swift │ │ │ ├── PodcastChapters.swift │ │ │ ├── PodcastEpisode.swift │ │ │ ├── PodcastFunding.swift │ │ │ ├── PodcastLocation+GeoURI.swift │ │ │ ├── PodcastLocation+OsmQuery.swift │ │ │ ├── PodcastLocation.swift │ │ │ ├── PodcastLocked.swift │ │ │ ├── PodcastPerson+Role.swift │ │ │ ├── PodcastPerson.swift │ │ │ ├── PodcastSeason.swift │ │ │ ├── PodcastSoundbite.swift │ │ │ ├── PodcastTranscript+MimeType.swift │ │ │ └── PodcastTranscript.swift │ │ ├── Video.swift │ │ ├── Wordpress │ │ │ ├── WPCategory.swift │ │ │ ├── WPPostMeta.swift │ │ │ ├── WPTag.swift │ │ │ ├── WordPressPost+RSSItem.swift │ │ │ └── WordPressPost.swift │ │ ├── YouTube │ │ │ └── YouTubeID.swift │ │ └── iTunes │ │ │ ├── iTunesDuration.swift │ │ │ ├── iTunesEpisode.swift │ │ │ ├── iTunesImage.swift │ │ │ └── iTunesOwner.swift │ ├── OPML │ │ ├── OPML+Body.swift │ │ ├── OPML+Head.swift │ │ ├── OPML+Outline.swift │ │ ├── OPML.swift │ │ └── OutlineType.swift │ └── SyndicationUpdate │ │ ├── SyndicationUpdate.swift │ │ ├── SyndicationUpdateFrequency.swift │ │ └── SyndicationUpdatePeriod.swift │ ├── KeyedDecodingContainerProtocol.swift │ ├── Substring.SubSequence.swift │ ├── SyndiKit.docc │ ├── Resources │ │ ├── favicon.svg │ │ └── logo.png │ └── SyndiKit.md │ └── URL.swift ├── Tests └── SyndiKitTests │ ├── BlogTests.swift │ ├── Content.Directories.swift │ ├── Content.ResultDictionary.swift │ ├── DecodingErrorTests.swift │ ├── Extensions │ ├── FileManager.swift │ ├── JSONFeed.swift │ ├── Sequence.swift │ ├── SiteCollection.swift │ ├── String.swift │ └── URL.swift │ ├── OPMLTests.swift │ ├── RSSCoded.Durations.swift │ ├── RSSCodedTests.swift │ ├── RSSGUIDTests.swift │ ├── RSSItemCategoryTests.swift │ ├── UTF8EncodedURLTests.swift │ ├── WordPressElementsTests.swift │ ├── WordpressTests.swift │ └── XMLStringIntTests.swift ├── codecov.yml ├── netlify.toml └── project.yml /.dockerignore: -------------------------------------------------------------------------------- 1 | .build/ 2 | .swiftpm/ 3 | DerivedData 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "swift" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | branches: [ "main" ] 19 | schedule: 20 | - cron: '16 9 * * 1' 21 | 22 | jobs: 23 | analyze: 24 | name: Analyze 25 | # Runner size impacts CodeQL analysis time. To learn more, please see: 26 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 27 | # - https://gh.io/supported-runners-and-hardware-resources 28 | # - https://gh.io/using-larger-runners 29 | # Consider using larger runners for possible analysis time improvements. 30 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 31 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} 32 | permissions: 33 | actions: read 34 | contents: read 35 | security-events: write 36 | 37 | strategy: 38 | fail-fast: false 39 | matrix: 40 | language: [ 'swift' ] 41 | # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] 42 | # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both 43 | # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 44 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 45 | 46 | steps: 47 | - name: Checkout repository 48 | uses: actions/checkout@v4 49 | 50 | # Initializes the CodeQL tools for scanning. 51 | - name: Initialize CodeQL 52 | uses: github/codeql-action/init@v3 53 | with: 54 | languages: ${{ matrix.language }} 55 | # If you wish to specify custom queries, you can do so here or in a config file. 56 | # By default, queries listed here will override any specified in a config file. 57 | # Prefix the list here with "+" to use these queries and those in the config file. 58 | 59 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 60 | # queries: security-extended,security-and-quality 61 | 62 | 63 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). 64 | # If this step fails, then you should remove it and run the build manually (see below) 65 | - name: Autobuild 66 | uses: github/codeql-action/autobuild@v3 67 | 68 | # ℹ️ Command-line programs to run using the OS shell. 69 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 70 | 71 | # If the Autobuild fails above, remove it and uncomment the following three lines. 72 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 73 | 74 | # - run: | 75 | # echo "Run, Build Application using script" 76 | # ./location_of_script_within_repo/buildscript.sh 77 | 78 | - name: Perform CodeQL Analysis 79 | uses: github/codeql-action/analyze@v3 80 | with: 81 | category: "/language:${{matrix.language}}" 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/swift,swiftpm,swiftpackagemanager,xcode,macos 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=swift,swiftpm,swiftpackagemanager,xcode,macos 3 | 4 | ### macOS ### 5 | # General 6 | .DS_Store 7 | .AppleDouble 8 | .LSOverride 9 | 10 | # Icon must end with two \r 11 | Icon 12 | 13 | 14 | # Thumbnails 15 | ._* 16 | 17 | # Files that might appear in the root of a volume 18 | .DocumentRevisions-V100 19 | .fseventsd 20 | .Spotlight-V100 21 | .TemporaryItems 22 | .Trashes 23 | .VolumeIcon.icns 24 | .com.apple.timemachine.donotpresent 25 | 26 | # Directories potentially created on remote AFP share 27 | .AppleDB 28 | .AppleDesktop 29 | Network Trash Folder 30 | Temporary Items 31 | .apdisk 32 | 33 | ### Swift ### 34 | # Xcode 35 | # 36 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 37 | 38 | ## User settings 39 | xcuserdata/ 40 | 41 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 42 | *.xcscmblueprint 43 | *.xccheckout 44 | 45 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 46 | build/ 47 | DerivedData/ 48 | *.moved-aside 49 | *.pbxuser 50 | !default.pbxuser 51 | *.mode1v3 52 | !default.mode1v3 53 | *.mode2v3 54 | !default.mode2v3 55 | *.perspectivev3 56 | !default.perspectivev3 57 | 58 | ## Obj-C/Swift specific 59 | *.hmap 60 | 61 | ## App packaging 62 | *.ipa 63 | *.dSYM.zip 64 | *.dSYM 65 | 66 | ## Playgrounds 67 | timeline.xctimeline 68 | playground.xcworkspace 69 | 70 | # Swift Package Manager 71 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 72 | Packages/ 73 | Package.pins 74 | #Package.resolved 75 | *.xcodeproj 76 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 77 | # hence it is not needed unless you have added a package configuration file to your project 78 | .swiftpm 79 | 80 | .build/ 81 | 82 | # CocoaPods 83 | # We recommend against adding the Pods directory to your .gitignore. However 84 | # you should judge for yourself, the pros and cons are mentioned at: 85 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 86 | # Pods/ 87 | # Add this line if you want to avoid checking in source code from the Xcode workspace 88 | # *.xcworkspace 89 | 90 | # Carthage 91 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 92 | # Carthage/Checkouts 93 | 94 | Carthage/Build/ 95 | 96 | # Accio dependency management 97 | Dependencies/ 98 | .accio/ 99 | 100 | # fastlane 101 | # It is recommended to not store the screenshots in the git repo. 102 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 103 | # For more information about the recommended setup visit: 104 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 105 | 106 | fastlane/report.xml 107 | fastlane/Preview.html 108 | fastlane/screenshots/**/*.png 109 | fastlane/test_output 110 | 111 | # Code Injection 112 | # After new code Injection tools there's a generated folder /iOSInjectionProject 113 | # https://github.com/johnno1962/injectionforxcode 114 | 115 | iOSInjectionProject/ 116 | 117 | ### SwiftPackageManager ### 118 | Packages 119 | xcuserdata 120 | *.xcodeproj 121 | 122 | 123 | ### SwiftPM ### 124 | 125 | 126 | ### Xcode ### 127 | # Xcode 128 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 129 | 130 | 131 | 132 | 133 | ## Gcc Patch 134 | /*.gcno 135 | 136 | ### Xcode Patch ### 137 | *.xcodeproj/* 138 | !*.xcodeproj/project.pbxproj 139 | !*.xcodeproj/xcshareddata/ 140 | !*.xcworkspace/contents.xcworkspacedata 141 | **/xcshareddata/WorkspaceSettings.xcsettings 142 | 143 | # End of https://www.toptal.com/developers/gitignore/api/swift,swiftpm,swiftpackagemanager,xcode,macos 144 | Output 145 | .mint -------------------------------------------------------------------------------- /.hound.yml: -------------------------------------------------------------------------------- 1 | swiftlint: 2 | config_file: .swiftlint.yml 3 | -------------------------------------------------------------------------------- /.htaccess: -------------------------------------------------------------------------------- 1 | # Enable custom routing. 2 | RewriteEngine On 3 | 4 | RedirectMatch ^/$ /documentation/ 5 | 6 | # Route documentation and tutorial pages. 7 | RewriteRule ^(documentation|tutorials)\/.*$ SyndiKit.doccarchive/index.html [L] 8 | 9 | RewriteRule /data/documentation.json SyndiKit.doccarchive/data/documentation/syndikit.json [L] 10 | 11 | # Route files and data for the documentation archive. 12 | # 13 | # If the file path doesn't exist in the website's root ... 14 | RewriteCond %{REQUEST_FILENAME} !-f 15 | RewriteCond %{REQUEST_FILENAME} !-d 16 | 17 | # ... route the request to that file path with the documentation archive. 18 | RewriteRule .* SyndiKit.doccarchive/$0 [L] -------------------------------------------------------------------------------- /.periphery.yml: -------------------------------------------------------------------------------- 1 | retain_public: true 2 | targets: 3 | - SyndiKit 4 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [SyndiKit] 5 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 5 2 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --indent 2 2 | --header strip 3 | --commas inline 4 | --disable wrapMultilineStatementBraces 5 | --extensionacl on-declarations 6 | --decimalgrouping 3,4 7 | --exclude .build, DerivedData 8 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | opt_in_rules: 2 | - anyobject_protocol 3 | - array_init 4 | - attributes 5 | - closure_body_length 6 | - closure_end_indentation 7 | - closure_spacing 8 | - collection_alignment 9 | - conditional_returns_on_newline 10 | - contains_over_filter_count 11 | - contains_over_filter_is_empty 12 | - contains_over_first_not_nil 13 | - contains_over_range_nil_comparison 14 | - convenience_type 15 | - discouraged_object_literal 16 | - discouraged_optional_boolean 17 | - empty_collection_literal 18 | - empty_count 19 | - empty_string 20 | - empty_xctest_method 21 | - enum_case_associated_values_count 22 | - expiring_todo 23 | - explicit_acl 24 | - explicit_init 25 | - explicit_self 26 | - explicit_top_level_acl 27 | - fallthrough 28 | - fatal_error_message 29 | - file_header 30 | - file_name 31 | - file_name_no_space 32 | - file_types_order 33 | - first_where 34 | - flatmap_over_map_reduce 35 | - force_unwrapping 36 | - function_default_parameter_at_end 37 | - ibinspectable_in_extension 38 | - identical_operands 39 | - implicit_return 40 | - implicitly_unwrapped_optional 41 | - indentation_width 42 | - joined_default_parameter 43 | - last_where 44 | - legacy_multiple 45 | - legacy_random 46 | - literal_expression_end_indentation 47 | - lower_acl_than_parent 48 | - missing_docs 49 | - modifier_order 50 | - multiline_arguments 51 | - multiline_arguments_brackets 52 | - multiline_function_chains 53 | - multiline_literal_brackets 54 | - multiline_parameters 55 | - nimble_operator 56 | - nslocalizedstring_key 57 | - nslocalizedstring_require_bundle 58 | - number_separator 59 | - object_literal 60 | - operator_usage_whitespace 61 | - optional_enum_case_matching 62 | - overridden_super_call 63 | - override_in_extension 64 | - pattern_matching_keywords 65 | - prefer_self_type_over_type_of_self 66 | - prefer_zero_over_explicit_init 67 | - private_action 68 | - private_outlet 69 | - prohibited_interface_builder 70 | - prohibited_super_call 71 | - quick_discouraged_call 72 | - quick_discouraged_focused_test 73 | - quick_discouraged_pending_test 74 | - reduce_into 75 | - redundant_nil_coalescing 76 | - redundant_type_annotation 77 | - required_enum_case 78 | - single_test_class 79 | - sorted_first_last 80 | - sorted_imports 81 | - static_operator 82 | - strict_fileprivate 83 | - strong_iboutlet 84 | - switch_case_on_newline 85 | - toggle_bool 86 | - trailing_closure 87 | - type_contents_order 88 | - unavailable_function 89 | - unneeded_parentheses_in_closure_argument 90 | - unowned_variable_capture 91 | - untyped_error_in_catch 92 | - unused_declaration 93 | - unused_import 94 | - vertical_parameter_alignment_on_call 95 | - vertical_whitespace_between_cases 96 | - vertical_whitespace_closing_braces 97 | - vertical_whitespace_opening_braces 98 | - xct_specific_matcher 99 | - yoda_condition 100 | cyclomatic_complexity: 101 | - 6 102 | - 9 103 | file_length: 104 | - 200 105 | - 550 106 | function_body_length: 107 | - 15 108 | - 25 109 | function_parameter_count: 8 110 | line_length: 111 | - 90 112 | - 90 113 | type_name: 114 | excluded: 115 | - iTunesDuration 116 | - iTunesEpisode 117 | - iTunesOwner 118 | - iTunesImage 119 | identifier_name: 120 | excluded: 121 | - id 122 | excluded: 123 | - Tests 124 | - DerivedData 125 | - .build 126 | indentation_width: 127 | indentation_width: 2 128 | -------------------------------------------------------------------------------- /Assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /Data/JSON/appfigures.youtube.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "https://jsonfeed.org/version/1", 3 | "title": "Appfigures", 4 | "home_page_url": "https://www.youtube.com/channel/UCfvk5lomVsbHBYWciRVNHlg", 5 | "author": { 6 | "name": "Appfigures" 7 | }, 8 | "items": [ 9 | { 10 | "guid": "yt:video:Eb6EeOqdTw8", 11 | "url": "https://www.youtube.com/watch?v=Eb6EeOqdTw8", 12 | "title": "What are Custom Product Pages in iOS 15 - The Basics", 13 | "date_published": "2021-06-19T02:40:31.000Z", 14 | "author": { 15 | "name": "Appfigures" 16 | } 17 | }, 18 | { 19 | "guid": "yt:video:nqoxX09Tnmo", 20 | "url": "https://www.youtube.com/watch?v=nqoxX09Tnmo", 21 | "title": "What's new for Subscription Apps in iOS 15?", 22 | "date_published": "2021-06-19T02:36:34.000Z", 23 | "author": { 24 | "name": "Appfigures" 25 | } 26 | }, 27 | { 28 | "guid": "yt:video:1WvLV3LuGHQ", 29 | "url": "https://www.youtube.com/watch?v=1WvLV3LuGHQ", 30 | "title": "These New App Store Guidelines are Interesting...", 31 | "date_published": "2021-06-19T02:33:33.000Z", 32 | "author": { 33 | "name": "Appfigures" 34 | } 35 | }, 36 | { 37 | "guid": "yt:video:BpwokwdeKQw", 38 | "url": "https://www.youtube.com/watch?v=BpwokwdeKQw", 39 | "title": "A/B Testing in the App Store is a BIG DEAL for App Makers!!!", 40 | "date_published": "2021-06-19T02:31:33.000Z", 41 | "author": { 42 | "name": "Appfigures" 43 | } 44 | }, 45 | { 46 | "guid": "yt:video:Er_wPp7LVGo", 47 | "url": "https://www.youtube.com/watch?v=Er_wPp7LVGo", 48 | "title": "What Can You Do with Custom Product Pages in the New App Store?", 49 | "date_published": "2021-06-19T02:26:39.000Z", 50 | "author": { 51 | "name": "Appfigures" 52 | } 53 | }, 54 | { 55 | "guid": "yt:video:nHUmqDxVTEc", 56 | "url": "https://www.youtube.com/watch?v=nHUmqDxVTEc", 57 | "title": "StoreKit 2 Will Forever Change How Apps Monetize", 58 | "date_published": "2021-06-19T02:16:25.000Z", 59 | "author": { 60 | "name": "Appfigures" 61 | } 62 | }, 63 | { 64 | "guid": "yt:video:CtoD1ZZkRxY", 65 | "url": "https://www.youtube.com/watch?v=CtoD1ZZkRxY", 66 | "title": "AF Chats - WWDC 2021 Recap with Joe Cieplinski", 67 | "date_published": "2021-06-17T15:54:19.000Z", 68 | "author": { 69 | "name": "Appfigures" 70 | } 71 | }, 72 | { 73 | "guid": "yt:video:M4SB2Ei94cI", 74 | "url": "https://www.youtube.com/watch?v=M4SB2Ei94cI", 75 | "title": "AF Chats - Anatomy of an App Store Scam with Kosta Eleftheriou", 76 | "date_published": "2021-06-07T10:45:01.000Z", 77 | "author": { 78 | "name": "Appfigures" 79 | } 80 | }, 81 | { 82 | "guid": "yt:video:HHXn70jc-qU", 83 | "url": "https://www.youtube.com/watch?v=HHXn70jc-qU", 84 | "title": "This Week in Apps #64 - TikTok the King Maker", 85 | "date_published": "2021-06-05T14:05:31.000Z", 86 | "author": { 87 | "name": "Appfigures" 88 | } 89 | }, 90 | { 91 | "guid": "yt:video:sG5Hm-h3NgE", 92 | "url": "https://www.youtube.com/watch?v=sG5Hm-h3NgE", 93 | "title": "This Week in Apps #63 - The Streaming Wars Continue", 94 | "date_published": "2021-06-05T14:05:15.000Z", 95 | "author": { 96 | "name": "Appfigures" 97 | } 98 | }, 99 | { 100 | "guid": "yt:video:x2ZdYZ5HGnI", 101 | "url": "https://www.youtube.com/watch?v=x2ZdYZ5HGnI", 102 | "title": "This Week in Apps #62 - The Money's Still Good", 103 | "date_published": "2021-05-22T01:06:34.000Z", 104 | "author": { 105 | "name": "Appfigures" 106 | } 107 | }, 108 | { 109 | "guid": "yt:video:LAZWoCvsN-w", 110 | "url": "https://www.youtube.com/watch?v=LAZWoCvsN-w", 111 | "title": "This Week in Apps #61 - ATT Continues to Wreak Havoc, Peacock Grows Up, and more.", 112 | "date_published": "2021-05-22T01:05:45.000Z", 113 | "author": { 114 | "name": "Appfigures" 115 | } 116 | }, 117 | { 118 | "guid": "yt:video:tPKTYwitA5Q", 119 | "url": "https://www.youtube.com/watch?v=tPKTYwitA5Q", 120 | "title": "Live App Teardown (May. 2021) - Sunbasket and Color Defense", 121 | "date_published": "2021-05-13T14:13:11.000Z", 122 | "author": { 123 | "name": "Appfigures" 124 | } 125 | }, 126 | { 127 | "guid": "yt:video:Hhz9iZrI1oQ", 128 | "url": "https://www.youtube.com/watch?v=Hhz9iZrI1oQ", 129 | "title": "This Week in Apps #60 - They're At It Again", 130 | "date_published": "2021-05-10T17:33:13.000Z", 131 | "author": { 132 | "name": "Appfigures" 133 | } 134 | }, 135 | { 136 | "guid": "yt:video:H2dh8KoYG8A", 137 | "url": "https://www.youtube.com/watch?v=H2dh8KoYG8A", 138 | "title": "This Week in Apps #59 - Do They Even Care?", 139 | "date_published": "2021-05-01T14:00:03.000Z", 140 | "author": { 141 | "name": "Appfigures" 142 | } 143 | } 144 | ] 145 | } -------------------------------------------------------------------------------- /Data/JSON/kilo.youtube.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "https://jsonfeed.org/version/1", 3 | "title": "Kilo Loco", 4 | "home_page_url": "https://www.youtube.com/channel/UCv75sKQFFIenWHrprnrR9aA", 5 | "author": { 6 | "name": "Kilo Loco" 7 | }, 8 | "items": [ 9 | { 10 | "guid": "yt:video:3hccNoPE59U", 11 | "url": "https://www.youtube.com/watch?v=3hccNoPE59U", 12 | "title": "What Developers Thought of WWDC 2021", 13 | "date_published": "2021-06-10T15:28:17.000Z", 14 | "author": { 15 | "name": "Kilo Loco" 16 | } 17 | }, 18 | { 19 | "guid": "yt:video:QW-oEfH_lgg", 20 | "url": "https://www.youtube.com/watch?v=QW-oEfH_lgg", 21 | "title": "Cache Images and URLs with Flutter | Day 27 - #30DaysOfFlutter", 22 | "date_published": "2021-05-11T20:01:53.000Z", 23 | "author": { 24 | "name": "Kilo Loco" 25 | } 26 | }, 27 | { 28 | "guid": "yt:video:0Tp70eB5WrM", 29 | "url": "https://www.youtube.com/watch?v=0Tp70eB5WrM", 30 | "title": "Flutter: Social Media App", 31 | "date_published": "2021-04-20T13:55:02.000Z", 32 | "author": { 33 | "name": "Kilo Loco" 34 | } 35 | }, 36 | { 37 | "guid": "yt:video:5E4T-Qb3l18", 38 | "url": "https://www.youtube.com/watch?v=5E4T-Qb3l18", 39 | "title": "Upload Images to Amplify Using Flutter | Day 26 - #30DaysOfFlutter", 40 | "date_published": "2021-04-20T03:27:01.000Z", 41 | "author": { 42 | "name": "Kilo Loco" 43 | } 44 | }, 45 | { 46 | "guid": "yt:video:8XyRqtZCnrU", 47 | "url": "https://www.youtube.com/watch?v=8XyRqtZCnrU", 48 | "title": "Flutter: Social Media Post Feed", 49 | "date_published": "2021-04-14T15:05:50.000Z", 50 | "author": { 51 | "name": "Kilo Loco" 52 | } 53 | }, 54 | { 55 | "guid": "yt:video:2Hoq6BafoEA", 56 | "url": "https://www.youtube.com/watch?v=2Hoq6BafoEA", 57 | "title": "Flutter: Remote Image Loading", 58 | "date_published": "2021-04-12T15:15:17.000Z", 59 | "author": { 60 | "name": "Kilo Loco" 61 | } 62 | }, 63 | { 64 | "guid": "yt:video:WPhfsCSSM7k", 65 | "url": "https://www.youtube.com/watch?v=WPhfsCSSM7k", 66 | "title": "Flutter: Remote Image Loading", 67 | "date_published": "2021-04-07T15:06:12.000Z", 68 | "author": { 69 | "name": "Kilo Loco" 70 | } 71 | }, 72 | { 73 | "guid": "yt:video:a4ix-CpsLLA", 74 | "url": "https://www.youtube.com/watch?v=a4ix-CpsLLA", 75 | "title": "Flutter: New Post Functionality for Social Media App", 76 | "date_published": "2021-04-06T15:49:50.000Z", 77 | "author": { 78 | "name": "Kilo Loco" 79 | } 80 | }, 81 | { 82 | "guid": "yt:video:Rgw8k6MuEuY", 83 | "url": "https://www.youtube.com/watch?v=Rgw8k6MuEuY", 84 | "title": "Flutter: New Post UI for Social Media App", 85 | "date_published": "2021-04-05T14:52:51.000Z", 86 | "author": { 87 | "name": "Kilo Loco" 88 | } 89 | }, 90 | { 91 | "guid": "yt:video:gho0MzVkS4c", 92 | "url": "https://www.youtube.com/watch?v=gho0MzVkS4c", 93 | "title": "Flutter with Swift and Xcode", 94 | "date_published": "2021-04-01T16:47:18.000Z", 95 | "author": { 96 | "name": "Kilo Loco" 97 | } 98 | }, 99 | { 100 | "guid": "yt:video:BMBZBjfnqy8", 101 | "url": "https://www.youtube.com/watch?v=BMBZBjfnqy8", 102 | "title": "Flutter: Create New Post on Social Media App", 103 | "date_published": "2021-03-31T15:06:08.000Z", 104 | "author": { 105 | "name": "Kilo Loco" 106 | } 107 | }, 108 | { 109 | "guid": "yt:video:jf6sU7fAeKE", 110 | "url": "https://www.youtube.com/watch?v=jf6sU7fAeKE", 111 | "title": "Flutter: Save Post Data", 112 | "date_published": "2021-03-30T16:02:41.000Z", 113 | "author": { 114 | "name": "Kilo Loco" 115 | } 116 | }, 117 | { 118 | "guid": "yt:video:7iZAZ0CwFKI", 119 | "url": "https://www.youtube.com/watch?v=7iZAZ0CwFKI", 120 | "title": "Camera and Photo Gallery Using Image Picker with Flutter | Day 25 - #30DaysOfFlutter", 121 | "date_published": "2021-03-30T04:44:18.000Z", 122 | "author": { 123 | "name": "Kilo Loco" 124 | } 125 | }, 126 | { 127 | "guid": "yt:video:gnxhNIle7VE", 128 | "url": "https://www.youtube.com/watch?v=gnxhNIle7VE", 129 | "title": "Flutter: Social Media Feed", 130 | "date_published": "2021-03-29T16:06:48.000Z", 131 | "author": { 132 | "name": "Kilo Loco" 133 | } 134 | }, 135 | { 136 | "guid": "yt:video:SBJFl-3wqx8", 137 | "url": "https://www.youtube.com/watch?v=SBJFl-3wqx8", 138 | "title": "User Profile UI with BLoC and Flutter | Day 24 - #30DaysOfFlutter", 139 | "date_published": "2021-03-25T17:29:43.000Z", 140 | "author": { 141 | "name": "Kilo Loco" 142 | } 143 | } 144 | ] 145 | } -------------------------------------------------------------------------------- /Data/JSON/stewart.youtube.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "https://jsonfeed.org/version/1", 3 | "title": "Stewart Lynch", 4 | "home_page_url": "https://www.youtube.com/channel/UCOWdR4sFkmolWkU2fg669Gg", 5 | "author": { 6 | "name": "Stewart Lynch" 7 | }, 8 | "items": [ 9 | { 10 | "guid": "yt:video:QrTChgzseVk", 11 | "url": "https://www.youtube.com/watch?v=QrTChgzseVk", 12 | "title": "SwiftUI Login Screen Workflow", 13 | "date_published": "2021-06-20T13:44:57.000Z", 14 | "author": { 15 | "name": "Stewart Lynch" 16 | } 17 | }, 18 | { 19 | "guid": "yt:video:4ochXtdrd70", 20 | "url": "https://www.youtube.com/watch?v=4ochXtdrd70", 21 | "title": "Sorting Objects in Swift with Multiple Criteria", 22 | "date_published": "2021-06-13T13:45:37.000Z", 23 | "author": { 24 | "name": "Stewart Lynch" 25 | } 26 | }, 27 | { 28 | "guid": "yt:video:lF9fOeUwWF8", 29 | "url": "https://www.youtube.com/watch?v=lF9fOeUwWF8", 30 | "title": "Switching Themes in SwiftUI", 31 | "date_published": "2021-06-06T13:54:22.000Z", 32 | "author": { 33 | "name": "Stewart Lynch" 34 | } 35 | }, 36 | { 37 | "guid": "yt:video:r29-06lbLmQ", 38 | "url": "https://www.youtube.com/watch?v=r29-06lbLmQ", 39 | "title": "CollectionView Paging Layout for SwiftUI and Layout Designer", 40 | "date_published": "2021-05-30T13:48:03.000Z", 41 | "author": { 42 | "name": "Stewart Lynch" 43 | } 44 | }, 45 | { 46 | "guid": "yt:video:u1kGK9RTEH4", 47 | "url": "https://www.youtube.com/watch?v=u1kGK9RTEH4", 48 | "title": "Channel Watcher - An Open Source, SwiftUI, Combine, iPhone, iPad, Mac app", 49 | "date_published": "2021-05-23T13:48:08.000Z", 50 | "author": { 51 | "name": "Stewart Lynch" 52 | } 53 | }, 54 | { 55 | "guid": "yt:video:tuHcwRe61KE", 56 | "url": "https://www.youtube.com/watch?v=tuHcwRe61KE", 57 | "title": "Demystifying Completion Handlers and Asynchronous Functions", 58 | "date_published": "2021-05-16T13:51:55.000Z", 59 | "author": { 60 | "name": "Stewart Lynch" 61 | } 62 | }, 63 | { 64 | "guid": "yt:video:kCJyhG8zjvY", 65 | "url": "https://www.youtube.com/watch?v=kCJyhG8zjvY", 66 | "title": "Navigation Bar Styling in SwiftUI", 67 | "date_published": "2021-05-09T13:28:24.000Z", 68 | "author": { 69 | "name": "Stewart Lynch" 70 | } 71 | }, 72 | { 73 | "guid": "yt:video:NY0LFoHQUbk", 74 | "url": "https://www.youtube.com/watch?v=NY0LFoHQUbk", 75 | "title": "Introduction to Generics in Swift", 76 | "date_published": "2021-05-02T13:35:13.000Z", 77 | "author": { 78 | "name": "Stewart Lynch" 79 | } 80 | }, 81 | { 82 | "guid": "yt:video:JmLGI3hdh6c", 83 | "url": "https://www.youtube.com/watch?v=JmLGI3hdh6c", 84 | "title": "Combine CurrentValueSubject", 85 | "date_published": "2021-04-29T13:50:35.000Z", 86 | "author": { 87 | "name": "Stewart Lynch" 88 | } 89 | }, 90 | { 91 | "guid": "yt:video:Cy7zokKsB4M", 92 | "url": "https://www.youtube.com/watch?v=Cy7zokKsB4M", 93 | "title": "Combine @Published properties and Just Publisher", 94 | "date_published": "2021-04-27T13:44:04.000Z", 95 | "author": { 96 | "name": "Stewart Lynch" 97 | } 98 | }, 99 | { 100 | "guid": "yt:video:1YtC5wVtiZ0", 101 | "url": "https://www.youtube.com/watch?v=1YtC5wVtiZ0", 102 | "title": "Combine PassthroughSubject and SwiftUI", 103 | "date_published": "2021-04-25T13:38:24.000Z", 104 | "author": { 105 | "name": "Stewart Lynch" 106 | } 107 | }, 108 | { 109 | "guid": "yt:video:dpmy-msRlCA", 110 | "url": "https://www.youtube.com/watch?v=dpmy-msRlCA", 111 | "title": "Responsible Error Handling In SwiftUI", 112 | "date_published": "2021-04-22T13:38:47.000Z", 113 | "author": { 114 | "name": "Stewart Lynch" 115 | } 116 | }, 117 | { 118 | "guid": "yt:video:PC0OXxAH-O0", 119 | "url": "https://www.youtube.com/watch?v=PC0OXxAH-O0", 120 | "title": "File DataPersistence in SwiftUI", 121 | "date_published": "2021-04-20T13:33:06.000Z", 122 | "author": { 123 | "name": "Stewart Lynch" 124 | } 125 | }, 126 | { 127 | "guid": "yt:video:EeSzURDoGCQ", 128 | "url": "https://www.youtube.com/watch?v=EeSzURDoGCQ", 129 | "title": "Building the ToDo app in SwiftUI", 130 | "date_published": "2021-04-18T13:43:42.000Z", 131 | "author": { 132 | "name": "Stewart Lynch" 133 | } 134 | }, 135 | { 136 | "guid": "yt:video:Uf_pmEwmpJo", 137 | "url": "https://www.youtube.com/watch?v=Uf_pmEwmpJo", 138 | "title": "Not another SwiftUI ToDo App!!!", 139 | "date_published": "2021-04-14T13:47:46.000Z", 140 | "author": { 141 | "name": "Stewart Lynch" 142 | } 143 | } 144 | ] 145 | } -------------------------------------------------------------------------------- /Data/JSON/tundsdev.youtube.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "https://jsonfeed.org/version/1", 3 | "title": "tundsdev", 4 | "home_page_url": "https://www.youtube.com/channel/UC7AuV86ZjR3YaEdb5USNvWQ", 5 | "author": { 6 | "name": "tundsdev" 7 | }, 8 | "items": [ 9 | { 10 | "guid": "yt:video:l-iN0kY_bmg", 11 | "url": "https://www.youtube.com/watch?v=l-iN0kY_bmg", 12 | "title": "Setup SwiftUI App with Firebase SPM (Swift Package Manager, SwiftUI Tutorial, Firebase Crashlytics)", 13 | "date_published": "2021-06-13T15:00:31.000Z", 14 | "author": { 15 | "name": "tundsdev" 16 | } 17 | }, 18 | { 19 | "guid": "yt:video:Dhfdg_6ksy8", 20 | "url": "https://www.youtube.com/watch?v=Dhfdg_6ksy8", 21 | "title": "Refactoring code in SwiftUI (SwiftUI Tutorial, SwiftUI Onboarding, SwiftUI)", 22 | "date_published": "2021-06-04T15:17:40.000Z", 23 | "author": { 24 | "name": "tundsdev" 25 | } 26 | }, 27 | { 28 | "guid": "yt:video:v-6qU-DqaZo", 29 | "url": "https://www.youtube.com/watch?v=v-6qU-DqaZo", 30 | "title": "SwiftUI Form Validation using Combine (SwiftUI Tutorial, Regular Expressions, Combine Framework)", 31 | "date_published": "2021-05-30T15:00:33.000Z", 32 | "author": { 33 | "name": "tundsdev" 34 | } 35 | }, 36 | { 37 | "guid": "yt:video:EWjLHRbQK4I", 38 | "url": "https://www.youtube.com/watch?v=EWjLHRbQK4I", 39 | "title": "Form Validation in UIKit using Combine Framework (Combine Framework, UIKit Tutorial, UIKit Form)", 40 | "date_published": "2021-05-23T15:00:04.000Z", 41 | "author": { 42 | "name": "tundsdev" 43 | } 44 | }, 45 | { 46 | "guid": "yt:video:4YIckjckiWI", 47 | "url": "https://www.youtube.com/watch?v=4YIckjckiWI", 48 | "title": "Create a Dynamic Form in UIKit (Compositional Layout, Diffable Data Source, UICollectionView)", 49 | "date_published": "2021-05-16T15:00:02.000Z", 50 | "author": { 51 | "name": "tundsdev" 52 | } 53 | }, 54 | { 55 | "guid": "yt:video:F5aDfGNdsac", 56 | "url": "https://www.youtube.com/watch?v=F5aDfGNdsac", 57 | "title": "Getting Started With Unit Testing in Swift (XCTest, Test Cases, Code Coverage)", 58 | "date_published": "2021-05-09T15:00:03.000Z", 59 | "author": { 60 | "name": "tundsdev" 61 | } 62 | }, 63 | { 64 | "guid": "yt:video:HXYqU5ClIk4", 65 | "url": "https://www.youtube.com/watch?v=HXYqU5ClIk4", 66 | "title": "Build Onboarding Screens in SwiftUI (TabView, PageTabViewStyle, SwiftUI Tutorial, @AppStorage)", 67 | "date_published": "2021-05-02T15:00:15.000Z", 68 | "author": { 69 | "name": "tundsdev" 70 | } 71 | }, 72 | { 73 | "guid": "yt:video:X2m0f2NoB10", 74 | "url": "https://www.youtube.com/watch?v=X2m0f2NoB10", 75 | "title": "Getting Started with Combine (Practical Combine Framework Examples in UIKit & SwiftUI)", 76 | "date_published": "2021-04-25T15:00:13.000Z", 77 | "author": { 78 | "name": "tundsdev" 79 | } 80 | }, 81 | { 82 | "guid": "yt:video:sYVXpRFmv54", 83 | "url": "https://www.youtube.com/watch?v=sYVXpRFmv54", 84 | "title": "How I Became A Lead App Developer Without A Computer Science Degree (Self Taught Programmer)", 85 | "date_published": "2021-04-18T14:10:35.000Z", 86 | "author": { 87 | "name": "tundsdev" 88 | } 89 | }, 90 | { 91 | "guid": "yt:video:j7a4jvHz4MM", 92 | "url": "https://www.youtube.com/watch?v=j7a4jvHz4MM", 93 | "title": "Dark Mode in SwiftUI using @Binding & @AppStorage (SwiftUI Tutorial, @Binding, @AppStorage)", 94 | "date_published": "2021-04-11T15:00:16.000Z", 95 | "author": { 96 | "name": "tundsdev" 97 | } 98 | }, 99 | { 100 | "guid": "yt:video:cmaZUw7GSGs", 101 | "url": "https://www.youtube.com/watch?v=cmaZUw7GSGs", 102 | "title": "SwiftUI Link, Open links in Safari in SwiftUI (SwiftUI Tutorial, SwiftUI Link View)", 103 | "date_published": "2021-04-07T15:06:53.000Z", 104 | "author": { 105 | "name": "tundsdev" 106 | } 107 | }, 108 | { 109 | "guid": "yt:video:fktqZRRQgyU", 110 | "url": "https://www.youtube.com/watch?v=fktqZRRQgyU", 111 | "title": "Build A Settings Screen In SwiftUI (SwiftUI Tutorial, SwiftUI Form, FormView)", 112 | "date_published": "2021-04-06T15:00:13.000Z", 113 | "author": { 114 | "name": "tundsdev" 115 | } 116 | }, 117 | { 118 | "guid": "yt:video:-kxqoTCgxkI", 119 | "url": "https://www.youtube.com/watch?v=-kxqoTCgxkI", 120 | "title": "How to land your first iOS developer job (Self Taught iOS Developer, iOS developer, SwiftUI)", 121 | "date_published": "2021-04-05T14:00:00.000Z", 122 | "author": { 123 | "name": "tundsdev" 124 | } 125 | }, 126 | { 127 | "guid": "yt:video:TZ3-iQ462Q8", 128 | "url": "https://www.youtube.com/watch?v=TZ3-iQ462Q8", 129 | "title": "Using UIKit in SwiftUI (SwiftUI Tutorial, SFSafariViewController, Xcode 12)", 130 | "date_published": "2021-03-28T15:00:09.000Z", 131 | "author": { 132 | "name": "tundsdev" 133 | } 134 | }, 135 | { 136 | "guid": "yt:video:cfwEt__pnvA", 137 | "url": "https://www.youtube.com/watch?v=cfwEt__pnvA", 138 | "title": "SwiftUI Redacted Tutorial - Loading Skeleton View - (SwiftUI Tutorial, Xcode 12, SwiftUI 2.0)", 139 | "date_published": "2021-03-21T15:09:18.000Z", 140 | "author": { 141 | "name": "tundsdev" 142 | } 143 | } 144 | ] 145 | } -------------------------------------------------------------------------------- /Data/JSON/vincent.youtube.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "https://jsonfeed.org/version/1", 3 | "title": "Vincent Pradeilles", 4 | "home_page_url": "https://www.youtube.com/channel/UCjkoQk5fOk6lH-shlm53vlw", 5 | "author": { 6 | "name": "Vincent Pradeilles" 7 | }, 8 | "items": [ 9 | { 10 | "guid": "yt:video:wUoDhf4tzvY", 11 | "url": "https://www.youtube.com/watch?v=wUoDhf4tzvY", 12 | "title": "Swift Tips #35 - reduce", 13 | "date_published": "2021-06-17T15:16:45.000Z", 14 | "author": { 15 | "name": "Vincent Pradeilles" 16 | } 17 | }, 18 | { 19 | "guid": "yt:video:p3qdi6KyzDk", 20 | "url": "https://www.youtube.com/watch?v=p3qdi6KyzDk", 21 | "title": "Five weird but valid Swift syntaxes 😮", 22 | "date_published": "2021-06-15T14:58:05.000Z", 23 | "author": { 24 | "name": "Vincent Pradeilles" 25 | } 26 | }, 27 | { 28 | "guid": "yt:video:rjw7Fesc1tk", 29 | "url": "https://www.youtube.com/watch?v=rjw7Fesc1tk", 30 | "title": "So what got the iOS community excited at WWDC 2021?", 31 | "date_published": "2021-06-10T15:08:51.000Z", 32 | "author": { 33 | "name": "Vincent Pradeilles" 34 | } 35 | }, 36 | { 37 | "guid": "yt:video:3OZ0FBOV0LQ", 38 | "url": "https://www.youtube.com/watch?v=3OZ0FBOV0LQ", 39 | "title": "My highlights of WWDC 2021 so far!", 40 | "date_published": "2021-06-08T14:52:46.000Z", 41 | "author": { 42 | "name": "Vincent Pradeilles" 43 | } 44 | }, 45 | { 46 | "guid": "yt:video:Oi_C7dD6ats", 47 | "url": "https://www.youtube.com/watch?v=Oi_C7dD6ats", 48 | "title": "How to SwiftUI #01 - Displaying images on top of each other", 49 | "date_published": "2021-06-03T15:03:30.000Z", 50 | "author": { 51 | "name": "Vincent Pradeilles" 52 | } 53 | }, 54 | { 55 | "guid": "yt:video:mzsz_Tit1HA", 56 | "url": "https://www.youtube.com/watch?v=mzsz_Tit1HA", 57 | "title": "Don't use this syntax! (Or you'll get a retain cycle 😱)", 58 | "date_published": "2021-06-01T14:55:12.000Z", 59 | "author": { 60 | "name": "Vincent Pradeilles" 61 | } 62 | }, 63 | { 64 | "guid": "yt:video:2vZz1v-2v1E", 65 | "url": "https://www.youtube.com/watch?v=2vZz1v-2v1E", 66 | "title": "Looking at some Shit Swift Code 💩, Live! 🎙 with Jérôme Alves", 67 | "date_published": "2021-05-31T15:19:17.000Z", 68 | "author": { 69 | "name": "Vincent Pradeilles" 70 | } 71 | }, 72 | { 73 | "guid": "yt:video:_VswqQqVlHo", 74 | "url": "https://www.youtube.com/watch?v=_VswqQqVlHo", 75 | "title": "Swift Tips #34 - defer", 76 | "date_published": "2021-05-27T15:01:18.000Z", 77 | "author": { 78 | "name": "Vincent Pradeilles" 79 | } 80 | }, 81 | { 82 | "guid": "yt:video:58IChtjCPGM", 83 | "url": "https://www.youtube.com/watch?v=58IChtjCPGM", 84 | "title": "Experimenting with Result Builders: it's harder than I thought 😰", 85 | "date_published": "2021-05-25T14:58:45.000Z", 86 | "author": { 87 | "name": "Vincent Pradeilles" 88 | } 89 | }, 90 | { 91 | "guid": "yt:video:6Abf8LDRRkI", 92 | "url": "https://www.youtube.com/watch?v=6Abf8LDRRkI", 93 | "title": "An introduction to Result Builders, Live! 🎙 with Antoine v.d. Lee", 94 | "date_published": "2021-05-23T16:19:58.000Z", 95 | "author": { 96 | "name": "Vincent Pradeilles" 97 | } 98 | }, 99 | { 100 | "guid": "yt:video:MZPeg8dqqzI", 101 | "url": "https://www.youtube.com/watch?v=MZPeg8dqqzI", 102 | "title": "Swift Tips #33 - CaseIterable", 103 | "date_published": "2021-05-20T15:05:51.000Z", 104 | "author": { 105 | "name": "Vincent Pradeilles" 106 | } 107 | }, 108 | { 109 | "guid": "yt:video:zhnqQCc032c", 110 | "url": "https://www.youtube.com/watch?v=zhnqQCc032c", 111 | "title": "Why should you use enums and associated values? 🤔", 112 | "date_published": "2021-05-18T14:59:43.000Z", 113 | "author": { 114 | "name": "Vincent Pradeilles" 115 | } 116 | }, 117 | { 118 | "guid": "yt:video:F4PJBEBVDYM", 119 | "url": "https://www.youtube.com/watch?v=F4PJBEBVDYM", 120 | "title": "Let's answer iOS Interview questions! (Using @Paul Hudson website)", 121 | "date_published": "2021-05-16T15:58:01.000Z", 122 | "author": { 123 | "name": "Vincent Pradeilles" 124 | } 125 | }, 126 | { 127 | "guid": "yt:video:FWjM8ESYgCA", 128 | "url": "https://www.youtube.com/watch?v=FWjM8ESYgCA", 129 | "title": "Swift Community #6 GraphQL (with Ellen Shapiro)", 130 | "date_published": "2021-05-13T14:56:01.000Z", 131 | "author": { 132 | "name": "Vincent Pradeilles" 133 | } 134 | }, 135 | { 136 | "guid": "yt:video:4TTSZZkdOs4", 137 | "url": "https://www.youtube.com/watch?v=4TTSZZkdOs4", 138 | "title": "Five tips to improve your Swift code 💪", 139 | "date_published": "2021-05-11T15:01:06.000Z", 140 | "author": { 141 | "name": "Vincent Pradeilles" 142 | } 143 | } 144 | ] 145 | } -------------------------------------------------------------------------------- /Data/OPML/category.opml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Illustrating the category attribute 5 | Mon, 31 Oct 2005 19:23:00 GMT 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Data/OPML/category_invalidExpansionState.opml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Illustrating the category attribute 5 | Mon, 31 Oct 2005 19:23:00 GMT 6 | one, two, three 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Data/OPML/directory.opml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | scriptingNewsDirectory.opml 5 | Thu, 13 Oct 2005 15:34:07 GMT 6 | Tue, 25 Oct 2005 21:33:57 GMT 7 | Dave Winer 8 | dwiner@yahoo.com 9 | 10 | 1 11 | 105 12 | 466 13 | 386 14 | 964 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /Data/OPML/placesLived.opml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | placesLived.opml 5 | Mon, 27 Feb 2006 12:09:48 GMT 6 | Mon, 27 Feb 2006 12:11:44 GMT 7 | Dave Winer 8 | http://www.opml.org/profiles/sendMail?usernum=1 9 | 1, 2, 5, 10, 13, 15 10 | 1 11 | 242 12 | 329 13 | 665 14 | 547 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /Data/OPML/simpleScript.opml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | workspace.userlandsamples.doSomeUpstreaming 5 | Mon, 11 Feb 2002 22:48:02 GMT 6 | Sun, 30 Oct 2005 03:30:17 GMT 7 | Dave Winer 8 | dwiner@yahoo.com 9 | 1, 2, 4 10 | 1 11 | 74 12 | 41 13 | 314 14 | 475 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /Data/OPML/states.opml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | states.opml 5 | Tue, 15 Mar 2005 16:35:45 GMT 6 | Thu, 14 Jul 2005 23:41:05 GMT 7 | Dave Winer 8 | dave@scripting.com 9 | 1, 6, 13, 16, 18, 20 10 | 1 11 | 106 12 | 106 13 | 558 14 | 479 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /Data/OPML/subscriptionList.opml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | mySubscriptions.opml 5 | Sat, 18 Jun 2005 12:11:52 GMT 6 | Tue, 02 Aug 2005 21:42:48 GMT 7 | Dave Winer 8 | dave@scripting.com 9 | 10 | 1 11 | 61 12 | 304 13 | 562 14 | 842 15 | 16 | 17 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /Data/urls.tsv: -------------------------------------------------------------------------------- 1 | https://swiftpackageindex.com/releases.rss swiftpackageindex 2 | https://mjtsai.com/blog/feed/ mjtsai 3 | http://feeds.ideveloper.co/ideveloperpodcast.xml ideveloper 4 | http://www.mokacoding.com/feed.xml mokacoding 5 | https://www.it-guy.com/podcasts/ajtm_feed.xml.rss it-guy 6 | https://developer.apple.com/news/rss/news.rss apple.developer 7 | https://wwdcnotes.com/feed.rss wwdcnotes 8 | https://developer.apple.com/news/releases/rss/releases.rss apple.releases 9 | https://www.relay.fm/radar/feed radar 10 | https://www.swiftbysundell.com/posts?format=RSS swiftbysundell 11 | https://swiftweeklybrief.com/feed.xml swiftweeklybrief 12 | https://cocoacasts.com/feed cocoacasts 13 | https://www.fivestars.blog/feed.xml fivestars 14 | https://www.raywenderlich.com/feed/podcast raywenderlich 15 | https://feeds.transistor.fm/empowerapps-show empowerapps-show 16 | https://www.andyibanez.com/tags/apple/index.xml andyibanez 17 | http://ios-goodies.com/rss ios-goodies 18 | https://blog.timac.org/index.xml timac 19 | https://www.revenuecat.com/blog/rss.xml revenuecat 20 | https://atomicbird.com/index.xml atomicbird 21 | http://www.enekoalonso.com/feed.xml enekoalonso 22 | https://iosdevweekly.com/issues.rss iosdevweekly 23 | https://www.avanderlee.com/feed/ avanderlee 24 | https://mecid.github.io/feed.xml mecid 25 | https://rhonabwy.com/feed/ rhonabwy 26 | https://www.hackingwithswift.com/articles/rss hackingwithswift 27 | https://www.advancedswift.com/rss advancedswift 28 | https://www.donnywals.com/feed/ donnywals 29 | https://www.youtube.com/feeds/videos.xml?channel_id=UC7AuV86ZjR3YaEdb5USNvWQ tundsdev.youtube 30 | https://www.youtube.com/feeds/videos.xml?channel_id=UCfvk5lomVsbHBYWciRVNHlg appfigures.youtube 31 | https://www.youtube.com/feeds/videos.xml?channel_id=UCjkoQk5fOk6lH-shlm53vlw vincent.youtube 32 | https://www.youtube.com/feeds/videos.xml?channel_id=UCOWdR4sFkmolWkU2fg669Gg stewart.youtube 33 | https://www.youtube.com/feeds/videos.xml?channel_id=UCv75sKQFFIenWHrprnrR9aA kilo.youtube 34 | https://us12.campaign-archive.com/feed?u=cb3bba007ed171091f55c47f0&id=584d0d5c40 brightdigit.newsletter 35 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM httpd:alpine 2 | 3 | RUN rm /usr/local/apache2/htdocs/index.html 4 | COPY Scripts/httpd.conf /usr/local/apache2/conf/httpd.conf -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 BrightDigit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Mintfile: -------------------------------------------------------------------------------- 1 | nicklockwood/SwiftFormat@0.47.0 2 | realm/SwiftLint@0.41.0 3 | peripheryapp/periphery@2.18.0 4 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "XMLCoder", 6 | "repositoryURL": "https://github.com/CoreOffice/XMLCoder", 7 | "state": { 8 | "branch": null, 9 | "revision": "b1e944cbd0ef33787b13f639a5418d55b3bed501", 10 | "version": "0.17.1" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | // swiftlint:disable explicit_top_level_acl explicit_acl 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "SyndiKit", 7 | products: [ 8 | .library( 9 | name: "SyndiKit", 10 | targets: ["SyndiKit"] 11 | ) 12 | ], 13 | dependencies: [ 14 | .package(url: "https://github.com/CoreOffice/XMLCoder", from: "0.17.1") 15 | ], 16 | targets: [ 17 | .target( 18 | name: "SyndiKit", 19 | dependencies: ["XMLCoder"] 20 | ), 21 | .testTarget( 22 | name: "SyndiKitTests", 23 | dependencies: ["SyndiKit"] 24 | ) 25 | ] 26 | ) 27 | -------------------------------------------------------------------------------- /Scripts/build_test_data.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | mkdir -p Data/XML 3 | mkdir -p Data/JSON 4 | { 5 | IFS=" " 6 | while read -r url file; do 7 | #echo "$url > $file" 8 | curl $url -Lo Data/XML/$file.xml 9 | curl https://feed2json.org/convert?url=$url -o Data/JSON/$file.json 10 | done 11 | } < ./Tests/SyndiKitTests/urls.tsv -------------------------------------------------------------------------------- /Scripts/docc.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | xcodebuild docbuild -scheme SyndiKit -destination 'platform=macOS' -derivedDataPath DerivedData 3 | docker run -d -p 8080:80 -v "$(pwd)/DerivedData/Build/Products/Debug:/usr/local/apache2/htdocs/" --rm -it $(docker build -q .) 4 | -------------------------------------------------------------------------------- /Scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -z "$SRCROOT" ]; then 4 | SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) 5 | PACKAGE_DIR="${SCRIPT_DIR}/.." 6 | else 7 | PACKAGE_DIR="${SRCROOT}" 8 | fi 9 | 10 | if [ -z "$GITHUB_ACTION" ]; then 11 | MINT_CMD="/opt/homebrew/bin/mint" 12 | else 13 | MINT_CMD="mint" 14 | fi 15 | 16 | export MINT_PATH="$PACKAGE_DIR/.mint" 17 | MINT_ARGS="-n -m $PACKAGE_DIR/Mintfile --silent" 18 | MINT_RUN="$MINT_CMD run $MINT_ARGS" 19 | 20 | pushd $PACKAGE_DIR 21 | 22 | $MINT_CMD bootstrap -m Mintfile 23 | 24 | if [ "$LINT_MODE" == "NONE" ]; then 25 | exit 26 | elif [ "$LINT_MODE" == "STRICT" ]; then 27 | SWIFTFORMAT_OPTIONS="" 28 | SWIFTLINT_OPTIONS="--strict" 29 | else 30 | SWIFTFORMAT_OPTIONS="" 31 | SWIFTLINT_OPTIONS="" 32 | fi 33 | 34 | pushd $PACKAGE_DIR 35 | 36 | if [ -z "$CI" ]; then 37 | $MINT_RUN swiftformat . 38 | $MINT_RUN swiftlint autocorrect 39 | fi 40 | 41 | $MINT_RUN periphery scan 42 | $MINT_RUN swiftformat --lint $SWIFTFORMAT_OPTIONS . 43 | $MINT_RUN swiftlint lint $SWIFTLINT_OPTIONS 44 | 45 | popd 46 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Character.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Character { 4 | internal func asOsmType() -> PodcastLocation.OsmQuery.OsmType? { 5 | .init(rawValue: String(self)) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Collection.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Collection { 4 | internal subscript(safe index: Index) -> Element? { 5 | indices.contains(index) ? self[index] : nil 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Common/Author.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// a person, corporation, or similar entity. 4 | public struct Author: Codable, Equatable { 5 | /// Conveys a human-readable name for the person. 6 | public let name: String 7 | 8 | /// Contains an email address for the person. 9 | public let email: String? 10 | 11 | /// Contains a home page for the person. 12 | public let uri: URL? 13 | 14 | public init(name: String) { 15 | self.name = name 16 | email = nil 17 | uri = nil 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Common/EntryCategory.swift: -------------------------------------------------------------------------------- 1 | /// Abstract category type. 2 | public protocol EntryCategory { 3 | /// Term used for the category 4 | var term: String { get } 5 | } 6 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Common/Entryable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Basic Feed type with abstract properties. 4 | public protocol Entryable { 5 | /// Unique Identifier of the Item. 6 | var id: EntryID { get } 7 | /// The URL of the item. 8 | var url: URL? { get } 9 | /// The title of the item. 10 | var title: String { get } 11 | /// HTML content of the item. 12 | var contentHtml: String? { get } 13 | /// The item synopsis. 14 | var summary: String? { get } 15 | /// Indicates when the item was published. 16 | var published: Date? { get } 17 | /// The author of the item. 18 | var authors: [Author] { get } 19 | /// Includes the item in one or more categories. 20 | var categories: [EntryCategory] { get } 21 | /// Creator of the item. 22 | var creators: [String] { get } 23 | /// Abstraction of Podcast episode or Youtube video info. 24 | var media: MediaContent? { get } 25 | /// Image URL of the Item. 26 | var imageURL: URL? { get } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Common/Feedable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Basic abstract Feed 4 | /// ## Topics 5 | /// 6 | /// ### Basic Properties 7 | /// 8 | /// - ``title`` 9 | /// - ``siteURL`` 10 | /// - ``summary`` 11 | /// - ``updated`` 12 | /// - ``authors`` 13 | /// - ``copyright`` 14 | /// - ``image`` 15 | /// - ``children`` 16 | /// 17 | /// ### Special Properties 18 | /// 19 | /// - ``youtubeChannelID`` 20 | /// - ``syndication`` 21 | public protocol Feedable { 22 | /// The name of the channel. 23 | var title: String { get } 24 | /// The URL to the website corresponding to the channel. 25 | var siteURL: URL? { get } 26 | /// Phrase or sentence describing the channel. 27 | var summary: String? { get } 28 | 29 | /// The last time the content of the channel changed. 30 | var updated: Date? { get } 31 | 32 | /// The author of the channel. 33 | var authors: [Author] { get } 34 | 35 | /// Copyright notice for content in the channel. 36 | var copyright: String? { get } 37 | 38 | /// Specifies a GIF, JPEG or PNG image that can be displayed with the channel. 39 | var image: URL? { get } 40 | 41 | /// Items or stories attached to the feed. 42 | var children: [Entryable] { get } 43 | 44 | /// For YouTube channels, this will be the youtube channel ID. 45 | var youtubeChannelID: String? { get } 46 | 47 | /// Provides syndication hints to aggregators and others 48 | var syndication: SyndicationUpdate? { get } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Common/Link.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | public struct Link: Codable { 3 | public let href: URL 4 | public let rel: String? 5 | } 6 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Common/Primitives/CData.swift: -------------------------------------------------------------------------------- 1 | /// #CDATA XML element. 2 | public struct CData: Codable, ExpressibleByStringLiteral, Equatable { 3 | public enum CodingKeys: String, CodingKey { 4 | case value = "#CDATA" 5 | } 6 | 7 | public var description: String { 8 | value 9 | } 10 | 11 | /// String value of the #CDATA element. 12 | public let value: String 13 | 14 | public init(stringLiteral value: String) { 15 | self.value = value 16 | } 17 | 18 | public init(from decoder: Decoder) throws { 19 | let value: String 20 | do { 21 | let container = try decoder.container(keyedBy: CodingKeys.self) 22 | value = try container.decode(String.self, forKey: .value) 23 | } catch { 24 | let container = try decoder.singleValueContainer() 25 | value = try container.decode(String.self) 26 | } 27 | self.value = value 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Common/Primitives/ListString.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct ListString< 4 | Value: LosslessStringConvertible & Equatable 5 | >: Codable, Equatable { 6 | public let values: [Value] 7 | 8 | internal init(values: [Value]) { 9 | self.values = values 10 | } 11 | 12 | public init(from decoder: Decoder) throws { 13 | let container = try decoder.singleValueContainer() 14 | let listString = try container.decode(String.self) 15 | let strings = listString.components(separatedBy: ",") 16 | let values = try strings 17 | .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } 18 | .filter { !$0.isEmpty } 19 | .map(Self.createValueFrom) 20 | self.init(values: values) 21 | } 22 | 23 | private static func createValueFrom(_ string: String) throws -> Value { 24 | guard let value: Value = .init(string) else { 25 | throw DecodingError.typeMismatch( 26 | Value.self, 27 | .init(codingPath: [], debugDescription: "Invalid value: \(string)") 28 | ) 29 | } 30 | return value 31 | } 32 | 33 | public func encode(to encoder: Encoder) throws { 34 | var container = encoder.singleValueContainer() 35 | let strings = values.map(String.init) 36 | let listString = strings.joined(separator: ",") 37 | try container.encode(listString) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Common/Primitives/UTF8EncodedURL.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | internal struct UTF8EncodedURL: Codable { 4 | internal let value: URL 5 | internal let string: String? 6 | 7 | internal init(from decoder: Decoder) throws { 8 | let container = try decoder.singleValueContainer() 9 | do { 10 | value = try container.decode(URL.self) 11 | string = nil 12 | } catch let error as DecodingError { 13 | let string = try container.decode(String.self) 14 | 15 | let encodedURLString = string.addingPercentEncoding( 16 | withAllowedCharacters: .urlQueryAllowed 17 | ) 18 | let encodedURL = encodedURLString.flatMap(URL.init(string:)) 19 | guard let encodedURL = encodedURL else { 20 | throw error 21 | } 22 | value = encodedURL 23 | self.string = string 24 | } 25 | } 26 | 27 | internal func encode(to encoder: Encoder) throws { 28 | var container = encoder.singleValueContainer() 29 | if let string = string { 30 | try container.encode(string) 31 | } else { 32 | try container.encode(value) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Common/Primitives/XMLStringInt.swift: -------------------------------------------------------------------------------- 1 | /// XML Element which contains a ``String`` parsable into a ``Integer``. 2 | public struct XMLStringInt: Codable, ExpressibleByIntegerLiteral { 3 | public typealias IntegerLiteralType = Int 4 | 5 | /// The underlying ``Int`` value. 6 | public let value: Int 7 | 8 | public init(integerLiteral value: Int) { 9 | self.value = value 10 | } 11 | 12 | public init(from decoder: Decoder) throws { 13 | let container = try decoder.singleValueContainer() 14 | let stringValue = try container.decode(String.self) 15 | .trimmingCharacters(in: .whitespacesAndNewlines) 16 | guard let value = Int(stringValue) else { 17 | let context = DecodingError.Context( 18 | codingPath: decoder.codingPath, 19 | debugDescription: "Not Able to Parse String Into Integer", 20 | underlyingError: nil 21 | ) 22 | throw DecodingError.typeMismatch(Int.self, context) 23 | } 24 | self.value = value 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Decoding/AnyDecoding.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | internal protocol AnyDecoding { 4 | static var label: String { get } 5 | func decodeFeed(data: Data) throws -> Feedable 6 | } 7 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Decoding/CustomDecoderSetup.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | internal protocol CustomDecoderSetup { 4 | func setup(decoder: TypeDecoder) 5 | } 6 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Decoding/DateFormatterDecoder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | internal struct DateFormatterDecoder { 4 | internal enum RSS { 5 | private static let dateFormatStrings = [ 6 | "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX", 7 | "yyyy-MM-dd'T'HH:mm:ssXXXXX", 8 | "E, d MMM yyyy HH:mm:ss zzz", 9 | "yyyy-MM-dd HH:mm:ss" 10 | ] 11 | 12 | internal static let decoder = DateFormatterDecoder( 13 | basedOnFormats: Self.dateFormatStrings 14 | ) 15 | } 16 | 17 | private let formatters: [DateFormatter] 18 | 19 | internal init(basedOnFormats formats: [String]) { 20 | formatters = formats.map(Self.isoPOSIX(withFormat:)) 21 | } 22 | 23 | private static func isoPOSIX(withFormat dateFormat: String) -> DateFormatter { 24 | let formatter = DateFormatter() 25 | formatter.calendar = Calendar(identifier: .iso8601) 26 | formatter.locale = Locale(identifier: "en_US_POSIX") 27 | formatter.timeZone = TimeZone(secondsFromGMT: 0) 28 | formatter.dateFormat = dateFormat 29 | return formatter 30 | } 31 | 32 | internal func decodeString(_ dateStr: String) -> Date? { 33 | for formatter in formatters { 34 | if let date = formatter.date(from: dateStr) { 35 | return date 36 | } 37 | } 38 | return nil 39 | } 40 | 41 | internal func decode(from decoder: Decoder) throws -> Date { 42 | let container = try decoder.singleValueContainer() 43 | let dateStr = try container.decode(String.self) 44 | 45 | if let date = decodeString(dateStr) { 46 | return date 47 | } 48 | 49 | let context = DecodingError.Context( 50 | codingPath: decoder.codingPath, 51 | debugDescription: "Invalid Date from '\(dateStr)'" 52 | ) 53 | throw DecodingError.dataCorrupted(context) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Decoding/DecodableFeed.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | internal protocol DecodableFeed: Decodable, Feedable { 4 | static var source: DecoderSetup { get } 5 | static var label: String { get } 6 | } 7 | 8 | extension DecodableFeed { 9 | internal static func decoding(using decoder: TypeDecoder) -> Decoding { 10 | Decoding(for: Self.self, using: decoder) 11 | } 12 | 13 | internal static func anyDecoding(using decoder: TypeDecoder) -> AnyDecoding { 14 | Self.decoding(using: decoder) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Decoding/DecoderSetup.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | internal protocol DecoderSetup { 4 | var source: DecoderSource { get } 5 | } 6 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Decoding/DecoderSource.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | internal enum DecoderSource: UInt8, DecoderSetup { 4 | case json = 0x007B 5 | case xml = 0x003C 6 | 7 | internal var source: DecoderSource { 8 | self 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Decoding/Decoding.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | internal struct Decoding: AnyDecoding { 4 | internal static var label: String { 5 | DecodingType.label 6 | } 7 | 8 | internal let decoder: TypeDecoder 9 | 10 | internal init(for _: DecodingType.Type, using decoder: TypeDecoder) { 11 | self.decoder = decoder 12 | } 13 | 14 | internal func decodeFeed(data: Data) throws -> Feedable { 15 | try decode(data: data) 16 | } 17 | 18 | internal func decode(data: Data) throws -> DecodingType { 19 | try decoder.decode(DecodingType.self, from: data) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Decoding/DecodingError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension DecodingError { 4 | internal struct Dictionary: Error { 5 | internal init?(errors: [String: DecodingError]) { 6 | guard errors.count > 1 else { 7 | return nil 8 | } 9 | self.errors = errors 10 | } 11 | 12 | internal let errors: [String: DecodingError] 13 | } 14 | 15 | internal static func failedAttempts(_ errors: [String: DecodingError]) -> Self { 16 | let context = DecodingError.Context( 17 | codingPath: [], 18 | debugDescription: "Failed to decode data with several decoders.", 19 | underlyingError: Dictionary(errors: errors) ?? errors.first?.value 20 | ) 21 | return DecodingError.dataCorrupted(context) 22 | } 23 | 24 | internal static func dataCorrupted( 25 | codingKey: CodingKey, 26 | debugDescription: String 27 | ) -> Self { 28 | DecodingError.dataCorrupted( 29 | .init( 30 | codingPath: [codingKey], 31 | debugDescription: debugDescription 32 | ) 33 | ) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Decoding/SynDecoder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XMLCoder 3 | 4 | /// An object that decodes instances of Feedable from JSON or XML objects. 5 | /// ## Topics 6 | /// 7 | /// ### Creating a Decoder 8 | /// 9 | /// - ``init()`` 10 | /// 11 | /// ### Decoding 12 | /// 13 | /// - ``decode(_:)`` 14 | public class SynDecoder { 15 | private static let defaultTypes: [DecodableFeed.Type] = [ 16 | RSSFeed.self, 17 | AtomFeed.self, 18 | JSONFeed.self 19 | ] 20 | 21 | private let defaultJSONDecoderSetup: (JSONDecoder) -> Void 22 | private let defaultXMLDecoderSetup: (XMLDecoder) -> Void 23 | private let types: [DecodableFeed.Type] 24 | 25 | private lazy var defaultXMLDecoder: XMLDecoder = { 26 | let decoder = XMLDecoder() 27 | self.defaultXMLDecoderSetup(decoder) 28 | return decoder 29 | }() 30 | 31 | private lazy var defaultJSONDecoder: JSONDecoder = { 32 | let decoder = JSONDecoder() 33 | self.defaultJSONDecoderSetup(decoder) 34 | return decoder 35 | }() 36 | 37 | // swiftlint:disable:next closure_body_length 38 | private lazy var decodings: [DecoderSource: [String: AnyDecoding]] = { 39 | let decodings = types.map { type -> (DecoderSource, AnyDecoding) in 40 | let source = type.source 41 | let setup = type.source as? CustomDecoderSetup 42 | let decoder: TypeDecoder 43 | 44 | switch (source.source, setup?.setup(decoder:)) { 45 | case let (.xml, .some(setup)): 46 | decoder = XMLDecoder() 47 | setup(decoder) 48 | 49 | case let (.json, .some(setup)): 50 | decoder = JSONDecoder() 51 | setup(decoder) 52 | 53 | case (.xml, .none): 54 | decoder = self.defaultXMLDecoder 55 | 56 | case (.json, .none): 57 | decoder = self.defaultJSONDecoder 58 | } 59 | 60 | return (source.source, type.anyDecoding(using: decoder)) 61 | } 62 | return Dictionary(grouping: decodings) { $0.0 } 63 | .mapValues { $0 64 | .map { $0.1 } 65 | .map { (type(of: $0).label, $0) } 66 | } 67 | .mapValues(Dictionary.init(uniqueKeysWithValues:)) 68 | }() 69 | 70 | internal init( 71 | types: [DecodableFeed.Type]? = nil, 72 | defaultJSONDecoderSetup: ((JSONDecoder) -> Void)? = nil, 73 | defaultXMLDecoderSetup: ((XMLDecoder) -> Void)? = nil 74 | ) { 75 | self.types = types ?? Self.defaultTypes 76 | self.defaultJSONDecoderSetup = defaultJSONDecoderSetup ?? Self.setupJSONDecoder(_:) 77 | self.defaultXMLDecoderSetup = defaultXMLDecoderSetup ?? Self.setupXMLDecoder(_:) 78 | } 79 | 80 | /// Creates an instance of ``RSSDecoder`` 81 | public convenience init() { 82 | self.init(types: nil, defaultJSONDecoderSetup: nil, defaultXMLDecoderSetup: nil) 83 | } 84 | 85 | internal static func setupJSONDecoder(_ decoder: JSONDecoder) { 86 | decoder.keyDecodingStrategy = .convertFromSnakeCase 87 | decoder.dateDecodingStrategy = .custom(DateFormatterDecoder.RSS.decoder.decode(from:)) 88 | } 89 | 90 | internal static func setupXMLDecoder(_ decoder: XMLDecoder) { 91 | decoder.keyDecodingStrategy = .convertFromSnakeCase 92 | decoder.dateDecodingStrategy = .custom(DateFormatterDecoder.RSS.decoder.decode(from:)) 93 | decoder.trimValueWhitespaces = false 94 | } 95 | 96 | /// Returns a ``Feedable`` object of the type you specify, decoded from a JSON object. 97 | /// - Parameter data: The JSON or XML object to decode. 98 | /// - Returns: A ``Feedable`` object 99 | /// 100 | /// If the data is not valid RSS, this method throws the 101 | /// `DecodingError.dataCorrupted(_:)` error. 102 | /// If a value within the RSS fails to decode, 103 | /// this method throws the corresponding error. 104 | /// 105 | /// ```swift 106 | /// let data = Data(contentsOf: "empowerapps-show.xml")! 107 | /// let decoder = SynDecoder() 108 | /// let feed = try decoder.decode(data) 109 | /// 110 | /// print(feed.title) // Prints "Empower Apps" 111 | /// ``` 112 | public func decode(_ data: Data) throws -> Feedable { 113 | var errors = [String: DecodingError]() 114 | 115 | guard let firstByte = data.first else { 116 | throw DecodingError.dataCorrupted( 117 | .init(codingPath: [], debugDescription: "Empty Data.") 118 | ) 119 | } 120 | guard let source = DecoderSource(rawValue: firstByte) else { 121 | throw DecodingError.dataCorrupted( 122 | .init(codingPath: [], debugDescription: "Unmatched First Byte: \(firstByte)") 123 | ) 124 | } 125 | guard let decodings = decodings[source] else { 126 | throw DecodingError.failedAttempts(errors) 127 | } 128 | for (label, decoding) in decodings { 129 | do { 130 | return try decoding.decodeFeed(data: data) 131 | } catch let decodingError as DecodingError { 132 | errors[label] = decodingError 133 | } 134 | } 135 | 136 | throw DecodingError.failedAttempts(errors) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Decoding/TypeDecoder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XMLCoder 3 | 4 | internal protocol TypeDecoder { 5 | func decode(_ type: T.Type, from data: Data) throws -> T where T: DecodableFeed 6 | } 7 | 8 | extension JSONDecoder: TypeDecoder {} 9 | 10 | extension XMLDecoder: TypeDecoder {} 11 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Dictionary.swift: -------------------------------------------------------------------------------- 1 | extension Dictionary { 2 | internal mutating func formUnion( 3 | _ collection: SequenceType, 4 | key: Key 5 | ) where Value == Set, SequenceType.Element == ElementType { 6 | if let set = self[key] { 7 | self[key] = set.union(collection) 8 | } else { 9 | self[key] = Set(collection) 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Blogs/CategoryDescriptor.swift: -------------------------------------------------------------------------------- 1 | /// A struct representing an Atom category. 2 | /// A descriptor for a category. 3 | /// 4 | /// - Note: This struct is publicly accessible. 5 | /// 6 | /// - Important: The ``title`` and ``description`` properties are read-only. 7 | /// 8 | /// - SeeAlso: ``Category`` 9 | /// - SeeAlso: ``EntryCategory`` 10 | public struct CategoryDescriptor { 11 | /// The title of the category. 12 | public let title: String 13 | 14 | /// The description of the category. 15 | public let description: String 16 | } 17 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Blogs/CategoryLanguage.swift: -------------------------------------------------------------------------------- 1 | /// A struct representing an Atom category. 2 | /// A struct representing a category in a specific language. 3 | /// 4 | /// - Parameters: 5 | /// - languageCategory: The category in a specific language. 6 | /// - language: The language of the category. 7 | /// 8 | /// - Note: This struct is used internally. 9 | /// 10 | /// - SeeAlso: ``SiteCategoryType`` 11 | /// - SeeAlso: ``CategoryDescriptor`` 12 | /// - SeeAlso: ``SiteLanguageType`` 13 | /// - SeeAlso: ``EntryCategory`` 14 | public struct CategoryLanguage { 15 | /// The type of the category. 16 | public let type: SiteCategoryType 17 | 18 | /// The descriptor of the category. 19 | public let descriptor: CategoryDescriptor 20 | 21 | /// The language of the category. 22 | public let language: SiteLanguageType 23 | 24 | /// A struct representing an Atom category. 25 | /// Initializes a ``CategoryLanguage`` instance. 26 | /// 27 | /// - Parameters: 28 | /// - languageCategory: The category in a specific language. 29 | /// - language: The language of the category. 30 | /// - SeeAlso: ``EntryCategory`` 31 | internal init(languageCategory: SiteLanguageCategory, language: SiteLanguageType) { 32 | type = languageCategory.slug 33 | descriptor = CategoryDescriptor( 34 | title: languageCategory.title, 35 | description: languageCategory.description 36 | ) 37 | self.language = language 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Blogs/Site.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A struct representing a website. 4 | public struct Site { 5 | /// The title of the website. 6 | public let title: String 7 | 8 | /// The author of the website. 9 | public let author: String 10 | 11 | /// The URL of the website. 12 | public let siteURL: URL 13 | 14 | /// The URL of the website's feed. 15 | public let feedURL: URL 16 | 17 | /// The URL of the website's Twitter page, if available. 18 | public let twitterURL: URL? 19 | 20 | /// The category of the website. 21 | public let category: SiteCategoryType 22 | 23 | /// The language of the website. 24 | public let language: SiteLanguageType 25 | 26 | /// Initializes a new ``Site`` instance. 27 | /// 28 | /// - Parameters: 29 | /// - site: The `SiteLanguageCategory.Site` instance to use as a base. 30 | /// - categoryType: The category type of the website. 31 | /// - languageType: The language type of the website. 32 | internal init( 33 | site: SiteLanguageCategory.Site, 34 | categoryType: SiteCategoryType, 35 | languageType: SiteLanguageType 36 | ) { 37 | title = site.title 38 | author = site.author 39 | siteURL = site.siteURL 40 | feedURL = site.feedURL 41 | twitterURL = site.twitterURL 42 | category = categoryType 43 | language = languageType 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Blogs/SiteCategory.swift: -------------------------------------------------------------------------------- 1 | /// A struct representing an Atom category. 2 | /// A struct representing a site category. 3 | /// 4 | /// - Note: This struct is used to categorize sites based on their type and descriptors. 5 | /// 6 | /// - Parameters: 7 | /// - type: The type of the site category. 8 | /// - descriptors: A dictionary mapping site language types to category descriptors. 9 | /// 10 | /// - Important: This struct should not be used directly. 11 | /// Instead, use the ``SiteCategoryBuilder`` to create instances of ``SiteCategory``. 12 | /// 13 | /// - SeeAlso: ``SiteCategoryType`` 14 | /// - SeeAlso: ``CategoryDescriptor`` 15 | /// - SeeAlso: ``CategoryLanguage`` 16 | /// - SeeAlso: ``SiteCategoryBuilder`` 17 | /// - SeeAlso: ``EntryCategory`` 18 | public struct SiteCategory { 19 | /// The type of the site category. 20 | public let type: SiteCategoryType 21 | 22 | /// A dictionary mapping site language types to category descriptors. 23 | public let descriptors: [SiteLanguageType: CategoryDescriptor] 24 | 25 | /// A struct representing an Atom category. 26 | /// Initializes a ``SiteCategory`` instance with the given languages. 27 | /// 28 | /// - Parameter languages: An array of ``CategoryLanguage`` instances. 29 | /// 30 | /// - Returns: A new ``SiteCategory`` instance 31 | /// if at least one language is provided, ``nil`` otherwise. 32 | /// - SeeAlso: ``EntryCategory`` 33 | internal init?(languages: [CategoryLanguage]) { 34 | guard let type = languages.first?.type else { 35 | return nil 36 | } 37 | self.type = type 38 | descriptors = Dictionary(grouping: languages) { $0.language } 39 | .compactMapValues { $0.first?.descriptor } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Blogs/SiteCategoryType.swift: -------------------------------------------------------------------------------- 1 | /// A type alias representing a site category. 2 | public typealias SiteCategoryType = String 3 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Blogs/SiteCollection.swift: -------------------------------------------------------------------------------- 1 | /// A collection of site language content. 2 | public typealias SiteCollection = [SiteLanguageContent] 3 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Blogs/SiteDirectoryBuilder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A builder for creating a site collection directory. 4 | public struct SiteCollectionDirectoryBuilder: SiteDirectoryBuilder { 5 | /// Initializes a new instance of ``SiteCollectionDirectoryBuilder``. 6 | public init() {} 7 | 8 | /// A struct representing an Atom category. 9 | /// Creates a site collection directory from a site collection. 10 | /// 11 | /// - Parameter blogs: The site collection to build the directory from. 12 | /// 13 | /// - Returns: A new instance of ``SiteCollectionDirectory``. 14 | /// - SeeAlso: ``EntryCategory`` 15 | public func directory(fromCollection blogs: SiteCollection) -> SiteCollectionDirectory { 16 | SiteCollectionDirectory(blogs: blogs) 17 | } 18 | } 19 | 20 | /// A protocol for building site directories. 21 | public protocol SiteDirectoryBuilder { 22 | /// The type of site directory to build. 23 | associatedtype SiteDirectoryType: SiteDirectory 24 | 25 | /// A struct representing an Atom category. 26 | /// Creates a site directory from a site collection. 27 | /// 28 | /// - Parameter blogs: The site collection to build the directory from. 29 | /// 30 | /// - Returns: A new instance of ``SiteDirectoryType``. 31 | /// - SeeAlso: ``EntryCategory`` 32 | func directory(fromCollection blogs: SiteCollection) -> SiteDirectoryType 33 | } 34 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Blogs/SiteLanguage.swift: -------------------------------------------------------------------------------- 1 | /// A struct representing an Atom category. 2 | /// A struct representing a site language. 3 | /// 4 | /// Use this struct to define the type and title of a site language. 5 | /// 6 | /// - Note: This struct is used internally and should not be directly instantiated. 7 | /// 8 | /// - Parameters: 9 | /// - content: The content of the site language. 10 | /// 11 | /// - SeeAlso: ``SiteLanguageType`` 12 | /// 13 | /// - Author: Your Name 14 | /// - SeeAlso: ``EntryCategory`` 15 | public struct SiteLanguage { 16 | /// The type of the site language. 17 | public let type: SiteLanguageType 18 | 19 | /// The title of the site language. 20 | public let title: String 21 | 22 | /// A struct representing an Atom category. 23 | /// Initializes a new ``SiteLanguage`` instance. 24 | /// 25 | /// - Parameters: 26 | /// - content: The content of the site language. 27 | /// 28 | /// - Returns: A new ``SiteLanguage`` instance. 29 | /// - SeeAlso: ``EntryCategory`` 30 | internal init(content: SiteLanguageContent) { 31 | type = content.language 32 | title = content.title 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Blogs/SiteLanguageCategory+Site.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // swiftlint:disable nesting 4 | extension SiteLanguageCategory { 5 | /// A ``struct`` representing a site. 6 | public struct Site: Codable { 7 | /// The title of the site. 8 | public let title: String 9 | 10 | /// The author of the site. 11 | public let author: String 12 | 13 | /// The URL of the site. 14 | public let siteURL: URL 15 | 16 | /// The URL of the site's feed. 17 | public let feedURL: URL 18 | 19 | /// The URL of the site's Twitter page. 20 | public let twitterURL: URL? 21 | 22 | /// Coding keys to map properties to JSON keys. 23 | internal enum CodingKeys: String, CodingKey { 24 | case title 25 | case author 26 | case siteURL = "site_url" 27 | case feedURL = "feed_url" 28 | case twitterURL = "twitter_url" 29 | } 30 | } 31 | } 32 | 33 | /// A type alias for `SiteLanguageCategory.Site`. 34 | public typealias SiteStub = SiteLanguageCategory.Site 35 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Blogs/SiteLanguageCategory.swift: -------------------------------------------------------------------------------- 1 | /// A struct representing an Atom category. 2 | /// A struct representing a category of site languages. 3 | /// 4 | /// - Note: This struct conforms to the ``Codable`` protocol. 5 | /// 6 | /// - Important: All properties of this struct are read-only. 7 | /// 8 | /// - SeeAlso: ``Site`` 9 | /// - SeeAlso: ``EntryCategory`` 10 | public struct SiteLanguageCategory: Codable { 11 | /// The title of the category. 12 | public let title: String 13 | 14 | /// The slug of the category. 15 | public let slug: String 16 | 17 | /// A description of the category. 18 | public let description: String 19 | 20 | /// An array of sites belonging to this category. 21 | public let sites: [Site] 22 | } 23 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Blogs/SiteLanguageContent.swift: -------------------------------------------------------------------------------- 1 | /// A struct representing an Atom category. 2 | /// A struct representing the content of a site in a specific language. 3 | /// 4 | /// - Note: This struct conforms to the ``Codable`` protocol. 5 | /// 6 | /// - Important: All properties of this struct are read-only. 7 | /// 8 | /// - SeeAlso: ``SiteLanguageCategory`` 9 | /// 10 | /// - Author: Your Name 11 | /// 12 | /// - Version: 1.0 13 | /// - SeeAlso: ``EntryCategory`` 14 | public struct SiteLanguageContent: Codable { 15 | /// The language of the site content. 16 | public let language: String 17 | 18 | /// The title of the site. 19 | public let title: String 20 | 21 | /// The categories of the site. 22 | public let categories: [SiteLanguageCategory] 23 | } 24 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Blogs/SiteLanguageType.swift: -------------------------------------------------------------------------------- 1 | /// A type representing the language of a website. 2 | public typealias SiteLanguageType = String 3 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Feeds/Atom/AtomCategory.swift: -------------------------------------------------------------------------------- 1 | /// A struct representing an Atom category. 2 | /// A struct representing an Atom category. 3 | /// 4 | /// - Note: This struct conforms to the ``Codable`` and ``EntryCategory`` protocols. 5 | /// 6 | /// - SeeAlso: ``EntryCategory`` 7 | /// - SeeAlso: ``EntryCategory`` 8 | public struct AtomCategory: Codable, EntryCategory { 9 | /// The term of the category. 10 | public let term: String 11 | } 12 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Feeds/Atom/AtomEntry.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A struct representing an entry in an Atom feed. 4 | public struct AtomEntry: Codable { 5 | /// The coding keys used for encoding and decoding. 6 | public enum CodingKeys: String, CodingKey { 7 | case id 8 | case title 9 | case published 10 | case content 11 | case updated 12 | case links = "link" 13 | case authors = "author" 14 | case atomCategories = "category" 15 | case youtubeVideoID = "yt:videoId" 16 | case youtubeChannelID = "yt:channelId" 17 | case creators = "dc:creator" 18 | case mediaGroup = "media:group" 19 | } 20 | 21 | /// A permanent, universally unique identifier for an entry. 22 | public let id: EntryID 23 | 24 | /// A human-readable title for the entry. 25 | public let title: String 26 | 27 | /// The most recent instant in time when the entry was published. 28 | public let published: Date? 29 | 30 | /// The content of the entry. 31 | public let content: String? 32 | 33 | /// The most recent instant in time when the entry was modified. 34 | public let updated: Date 35 | 36 | /// The categories associated with the entry. 37 | public let atomCategories: [AtomCategory] 38 | 39 | /// The links associated with the entry. 40 | public let links: [Link] 41 | 42 | /// The authors of the entry. 43 | public let authors: [Author] 44 | 45 | /// The YouTube video ID, if the entry is from a YouTube channel. 46 | public let youtubeVideoID: String? 47 | 48 | /// The YouTube channel ID, if the entry is from a YouTube channel. 49 | public let youtubeChannelID: String? 50 | 51 | /// The creators of the entry. 52 | public let creators: [String] 53 | 54 | /// The media group associated with the entry. 55 | public let mediaGroup: AtomMediaGroup? 56 | } 57 | 58 | extension AtomEntry: Entryable { 59 | /// The categories associated with the entry. 60 | public var categories: [EntryCategory] { 61 | atomCategories 62 | } 63 | 64 | /// The URL of the entry. 65 | public var url: URL? { 66 | links.first?.href 67 | } 68 | 69 | /// The HTML content of the entry. 70 | public var contentHtml: String? { 71 | content?.trimmingCharacters(in: .whitespacesAndNewlines) 72 | } 73 | 74 | /// The summary of the entry. 75 | public var summary: String? { 76 | nil 77 | } 78 | 79 | /// The media content of the entry. 80 | public var media: MediaContent? { 81 | YouTubeIDProperties(entry: self).map(Video.youtube).map(MediaContent.video) 82 | } 83 | 84 | /// The URL of the entry's image. 85 | public var imageURL: URL? { 86 | mediaGroup?.thumbnails.first?.url 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Feeds/Atom/AtomFeed.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A struct representing an Atom category. 4 | /// An XML-based Web content and metadata syndication format. 5 | /// 6 | /// Based on the 7 | /// [specifications here](https://datatracker.ietf.org/doc/html/rfc4287#section-4.1.2). 8 | /// - SeeAlso: ``EntryCategory`` 9 | public struct AtomFeed { 10 | public enum CodingKeys: String, CodingKey { 11 | case id 12 | case title 13 | case description 14 | case subtitle 15 | case published 16 | case pubDate 17 | case links = "link" 18 | case entries = "entry" 19 | case authors = "author" 20 | case youtubeChannelID = "yt:channelId" 21 | } 22 | 23 | /// Identifies the feed using a universally unique and permanent URI. 24 | /// If you have a long-term, renewable lease on your Internet domain name, 25 | /// then you can feel free to use your website's address. 26 | public let id: String 27 | 28 | /// Contains a human readable title for the feed. 29 | /// Often the same as the title of the associated website. 30 | public let title: String 31 | 32 | /// Contains a human-readable description or subtitle for the feed. 33 | public let description: String? 34 | 35 | /// Contains a human-readable description or subtitle for the feed. 36 | public let subtitle: String? 37 | 38 | /// The publication date for the content in the channel. 39 | public let published: Date? 40 | 41 | /// The publication date for the content in the channel. 42 | public let pubDate: Date? 43 | 44 | /// A reference from an entry or feed to a Web resource. 45 | public let links: [Link] 46 | 47 | /// Individual entries of the feed. 48 | public let entries: [AtomEntry] 49 | 50 | /// The author of the feed. 51 | public let authors: [Author] 52 | 53 | /// YouTube channel ID, if from a YouTube channel. 54 | public let youtubeChannelID: String? 55 | } 56 | 57 | extension AtomFeed: DecodableFeed { 58 | /// The source of the decoder for AtomFeed. 59 | internal static let source: DecoderSetup = DecoderSource.xml 60 | 61 | /// The label for AtomFeed. 62 | internal static let label: String = "Atom" 63 | 64 | /// The summary of the AtomFeed. 65 | public var summary: String? { 66 | description ?? subtitle 67 | } 68 | 69 | /// The children of the AtomFeed. 70 | public var children: [Entryable] { 71 | entries 72 | } 73 | 74 | /// The site URL of the AtomFeed. 75 | public var siteURL: URL? { 76 | links.first { $0.rel != "self" }?.href 77 | } 78 | 79 | /// The updated date of the AtomFeed. 80 | public var updated: Date? { 81 | pubDate ?? published 82 | } 83 | 84 | /// The copyright of the AtomFeed. 85 | public var copyright: String? { 86 | nil 87 | } 88 | 89 | /// The image URL of the AtomFeed. 90 | public var image: URL? { 91 | nil 92 | } 93 | 94 | /// The syndication update of the AtomFeed. 95 | public var syndication: SyndicationUpdate? { 96 | nil 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Feeds/Atom/AtomMedia.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A struct representing an Atom category. 4 | /// Media structure which enables content publishers and bloggers 5 | /// to syndicate multimedia content such as TV and video clips, movies, images and audio. 6 | /// 7 | /// For more details, check out 8 | /// [the Media RSS Specification](https://www.rssboard.org/media-rss). 9 | /// - SeeAlso: ``EntryCategory`` 10 | public struct AtomMedia: Codable { 11 | /// A struct representing an Atom category. 12 | /// The type of object. 13 | /// 14 | /// While this attribute can at times seem redundant if type is supplied, 15 | /// it is included because it simplifies decision making on the reader side, 16 | /// as well as flushes out any ambiguities between MIME type and object type. 17 | /// It is an optional attribute. 18 | /// - SeeAlso: ``EntryCategory`` 19 | public let url: URL 20 | 21 | /// The direct URL to the media object. 22 | public let medium: String? 23 | 24 | public init(from decoder: Decoder) throws { 25 | let container = try decoder.container(keyedBy: CodingKeys.self) 26 | url = try container.decode(UTF8EncodedURL.self, forKey: .url).value 27 | medium = try container.decodeIfPresent(String.self, forKey: .medium) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Feeds/Atom/AtomMediaGroup.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A group of media elements in an Atom feed. 4 | public struct AtomMediaGroup: Codable { 5 | /// Coding keys for encoding and decoding. 6 | public enum CodingKeys: String, CodingKey { 7 | case title = "media:title" 8 | case descriptions = "media:description" 9 | case contents = "media:content" 10 | case thumbnails = "media:thumbnail" 11 | } 12 | 13 | /// The title of the media group. 14 | public let title: String? 15 | 16 | /// The media elements within the group. 17 | public let contents: [AtomMedia] 18 | 19 | /// The thumbnail images associated with the media group. 20 | public let thumbnails: [AtomMedia] 21 | 22 | /// The descriptions of the media group. 23 | public let descriptions: [String] 24 | } 25 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Feeds/JSONFeed/JSONFeed.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A struct representing an Atom category. 4 | /// A struct representing a JSON feed. 5 | /// 6 | /// - Note: This struct conforms to the ``DecodableFeed`` protocol. 7 | /// 8 | /// - SeeAlso: ``DecodableFeed`` 9 | /// - SeeAlso: ``EntryCategory`` 10 | public struct JSONFeed { 11 | /// The version of the JSON feed. 12 | public let version: URL 13 | 14 | /// The title of the JSON feed. 15 | public let title: String 16 | 17 | /// The URL of the home page associated with the feed. 18 | public let homePageUrl: URL 19 | 20 | /// A description of the JSON feed. 21 | public let description: String? 22 | 23 | /// The author of the feed. 24 | public let author: Author? 25 | 26 | /// The items in the JSON feed. 27 | public let items: [JSONItem] 28 | } 29 | 30 | extension JSONFeed: DecodableFeed { 31 | /// The source of the decoder for JSON feed. 32 | internal static let source: DecoderSetup = DecoderSource.json 33 | 34 | /// The label for the JSON feed. 35 | internal static let label: String = "JSON" 36 | 37 | /// The YouTube channel ID associated with the feed. 38 | public var youtubeChannelID: String? { 39 | nil 40 | } 41 | 42 | /// The children of the JSON feed. 43 | public var children: [Entryable] { 44 | items 45 | } 46 | 47 | /// The summary of the JSON feed. 48 | public var summary: String? { 49 | description 50 | } 51 | 52 | /// The site URL associated with the feed. 53 | public var siteURL: URL? { 54 | homePageUrl 55 | } 56 | 57 | /// The last updated date of the JSON feed. 58 | public var updated: Date? { 59 | nil 60 | } 61 | 62 | /// The copyright information of the JSON feed. 63 | public var copyright: String? { 64 | nil 65 | } 66 | 67 | /// The image URL associated with the feed. 68 | public var image: URL? { 69 | nil 70 | } 71 | 72 | /// The syndication update information of the JSON feed. 73 | public var syndication: SyndicationUpdate? { 74 | nil 75 | } 76 | 77 | /// The authors of the JSON feed. 78 | public var authors: [Author] { 79 | guard let author = author else { 80 | return [] 81 | } 82 | return [author] 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Feeds/JSONFeed/JSONItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A struct representing an Atom category. 4 | /// A struct representing an item in JSON format. 5 | /// - SeeAlso: ``EntryCategory`` 6 | public struct JSONItem: Codable { 7 | /// The unique identifier of the item. 8 | public let guid: EntryID 9 | 10 | /// The URL associated with the item. 11 | public let url: URL? 12 | 13 | /// The title of the item. 14 | public let title: String 15 | 16 | /// The HTML content of the item. 17 | public let contentHtml: String? 18 | 19 | /// A summary of the item. 20 | public let summary: String? 21 | 22 | /// The date the item was published. 23 | public let datePublished: Date? 24 | 25 | /// The author of the item. 26 | public let author: Author? 27 | } 28 | 29 | extension JSONItem: Entryable { 30 | /// A struct representing an Atom category. 31 | /// Returns an array of authors for the item. 32 | /// - SeeAlso: ``EntryCategory`` 33 | public var authors: [Author] { 34 | guard let author = author else { 35 | return [] 36 | } 37 | return [author] 38 | } 39 | 40 | /// The URL of the item's image. 41 | public var imageURL: URL? { 42 | nil 43 | } 44 | 45 | /// A struct representing an Atom category. 46 | /// An array of creators associated with the item. 47 | /// - SeeAlso: ``EntryCategory`` 48 | public var creators: [String] { 49 | [] 50 | } 51 | 52 | /// The date the item was published. 53 | public var published: Date? { 54 | datePublished 55 | } 56 | 57 | /// The unique identifier of the item. 58 | public var id: EntryID { 59 | guid 60 | } 61 | 62 | /// A struct representing an Atom category. 63 | /// An array of categories associated with the item. 64 | /// - SeeAlso: ``EntryCategory`` 65 | public var categories: [EntryCategory] { 66 | [] 67 | } 68 | 69 | /// The media content associated with the item. 70 | public var media: MediaContent? { 71 | nil 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Feeds/RSS/Enclosure.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A struct representing an enclosure for a resource. 4 | public struct Enclosure: Codable { 5 | internal enum CodingKeys: String, CodingKey { 6 | case url 7 | case type 8 | case length 9 | } 10 | 11 | /// The URL of the enclosure. 12 | public let url: URL 13 | 14 | /// The type of the enclosure. 15 | public let type: String 16 | 17 | /// The length of the enclosure, if available. 18 | public let length: Int? 19 | 20 | /// Initializes a new ``Enclosure`` instance from a decoder. 21 | /// 22 | /// - Parameter decoder: The decoder to read data from. 23 | /// - Throws: An error if the decoding process fails. 24 | public init(from decoder: Decoder) throws { 25 | let container = try decoder.container(keyedBy: CodingKeys.self) 26 | url = try container.decode(UTF8EncodedURL.self, forKey: .url).value 27 | type = try container.decode(String.self, forKey: .type) 28 | length = try Self.decodeLength(from: container) 29 | } 30 | 31 | /// Decodes the length of the enclosure from the given container. 32 | /// 33 | /// - Parameter container: The container to decode from. 34 | /// - Returns: The length of the enclosure, or ``nil`` if not available. 35 | /// - Throws: An error if the decoding process fails. 36 | private static func decodeLength( 37 | from container: KeyedDecodingContainer 38 | ) throws -> Int? { 39 | if container.contains(.length) { 40 | do { 41 | return try container.decode(Int.self, forKey: .length) 42 | } catch { 43 | let lengthString = try container.decode(String.self, forKey: .length) 44 | if lengthString.isEmpty { 45 | return nil 46 | } else if let length = Int(lengthString) { 47 | return length 48 | } else { 49 | throw error 50 | } 51 | } 52 | } else { 53 | return nil 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Feeds/RSS/EntryID.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// An identifier for an entry based on the RSS guid. 4 | /// 5 | /// - Note: This enum conforms to 6 | /// ``Codable``, ``Equatable``, and ``LosslessStringConvertible``. 7 | public enum EntryID: Codable, Equatable, LosslessStringConvertible { 8 | /// An identifier in URL format. 9 | case url(URL) 10 | 11 | /// An identifier in UUID format. 12 | case uuid(UUID) 13 | 14 | /// An identifier in string path format. 15 | /// 16 | /// This format is commonly used by YouTube's RSS feed, in the format of: 17 | /// ``` 18 | /// yt:video:(YouTube Video ID) 19 | /// ``` 20 | case path([String], separatedBy: String) 21 | 22 | /// An identifier in plain un-parsable string format. 23 | case string(String) 24 | 25 | /// A string representation of the entry identifier. 26 | public var description: String { 27 | let string: String 28 | switch self { 29 | case let .url(url): 30 | string = url.absoluteString 31 | 32 | case let .uuid(uuid): 33 | string = uuid.uuidString.lowercased() 34 | 35 | case let .path(components, separatedBy: separator): 36 | string = components.joined(separator: separator) 37 | 38 | case let .string(value): 39 | string = value 40 | } 41 | return string 42 | } 43 | 44 | /// Initializes an ``EntryID`` from a string. 45 | /// 46 | /// - Parameter description: The string representation of the entry identifier. 47 | /// - Note: This initializer will never return a ``nil`` instance. 48 | /// To avoid the ``Optional`` result, use `init(string:)` instead. 49 | public init?(_ description: String) { 50 | self.init(string: description) 51 | } 52 | 53 | /// Initializes an ``EntryID`` from a string. 54 | /// 55 | /// - Parameter string: The string representation of the entry identifier. 56 | /// - Note: Use this initializer instead of `init(_:)` to avoid the ``Optional`` result. 57 | public init(string: String) { 58 | if let url = URL(strict: string) { 59 | self = .url(url) 60 | } else if let uuid = UUID(uuidString: string) { 61 | self = .uuid(uuid) 62 | } else { 63 | let components = string.components(separatedBy: ":") 64 | if components.count > 1 { 65 | self = .path(components, separatedBy: ":") 66 | } else { 67 | let components = string.components(separatedBy: "/") 68 | if components.count > 1 { 69 | self = .path(components, separatedBy: "/") 70 | } else { 71 | self = .string(string) 72 | } 73 | } 74 | } 75 | } 76 | 77 | /// Initializes an ``EntryID`` from a decoder. 78 | /// 79 | /// - Parameter decoder: The decoder to read data from. 80 | /// - Throws: An error if the decoding process fails. 81 | public init(from decoder: Decoder) throws { 82 | let container = try decoder.singleValueContainer() 83 | let string = try container.decode(String.self) 84 | self.init(string: string) 85 | } 86 | 87 | /// Encodes the ``EntryID`` into the given encoder. 88 | /// 89 | /// - Parameter encoder: The encoder to write data to. 90 | /// - Throws: An error if the encoding process fails. 91 | public func encode(to encoder: Encoder) throws { 92 | var container = encoder.singleValueContainer() 93 | try container.encode(description) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Feeds/RSS/RSSChannel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A struct representing an Atom category. 4 | /// A struct representing information about the channel (metadata) and its contents. 5 | /// 6 | /// - Note: This struct conforms to the ``Codable`` protocol. 7 | /// 8 | /// - Remark: The ``CodingKeys`` enum is used to specify the coding keys for the struct. 9 | /// 10 | /// - SeeAlso: ``RSSItem`` 11 | /// - SeeAlso: ``RSSImage`` 12 | /// - SeeAlso: ``Author`` 13 | /// - SeeAlso: ``WordPressElements.Category`` 14 | /// - SeeAlso: ``WordPressElements.Tag`` 15 | /// - SeeAlso: ``iTunesOwner`` 16 | /// - SeeAlso: ``PodcastLocked`` 17 | /// - SeeAlso: ``PodcastFunding`` 18 | /// - SeeAlso: ``PodcastPerson`` 19 | /// - SeeAlso: ``EntryCategory`` 20 | public struct RSSChannel: Codable { 21 | internal enum CodingKeys: String, CodingKey { 22 | case title 23 | case link 24 | case description 25 | case lastBuildDate 26 | case pubDate 27 | case ttl 28 | case syUpdatePeriod = "sy:updatePeriod" 29 | case syUpdateFrequency = "sy:updateFrequency" 30 | case items = "item" 31 | case itunesAuthor = "itunes:author" 32 | case itunesImage = "itunes:image" 33 | case itunesOwner = "itunes:owner" 34 | case copyright 35 | case image 36 | case author 37 | case wpCategories = "wp:category" 38 | case wpTags = "wp:tag" 39 | case wpBaseSiteURL = "wp:baseSiteUrl" 40 | case wpBaseBlogURL = "wp:baseBlogUrl" 41 | case podcastLocked = "podcast:locked" 42 | case podcastFundings = "podcast:funding" 43 | case podcastPeople = "podcast:person" 44 | } 45 | 46 | /// The name of the channel. 47 | public let title: String 48 | 49 | /// The URL to the HTML website corresponding to the channel. 50 | public let link: URL 51 | 52 | /// Phrase or sentence describing the channel. 53 | public let description: String? 54 | 55 | /// The last time the content of the channel changed. 56 | public let lastBuildDate: Date? 57 | 58 | /// Indicates the publication date and time of the feed's content. 59 | public let pubDate: Date? 60 | 61 | /// TTL stands for time to live. 62 | /// It's a number of minutes that indicates 63 | /// how long a channel can be cached before refreshing from the source. 64 | public let ttl: Int? 65 | 66 | /// Describes the period over which the channel format is updated. 67 | public let syUpdatePeriod: SyndicationUpdatePeriod? 68 | 69 | /// Used to describe the frequency of updates in relation to the update period. 70 | /// A positive integer indicates how many times in that period the channel is updated. 71 | public let syUpdateFrequency: SyndicationUpdateFrequency? 72 | 73 | /// The items contained in the channel. 74 | public let items: [RSSItem] 75 | 76 | /// The author of the channel. 77 | public let itunesAuthor: String? 78 | 79 | /// The image associated with the channel. 80 | public let itunesImage: String? 81 | 82 | /// The owner of the channel. 83 | public let itunesOwner: iTunesOwner? 84 | 85 | /// Copyright notice for content in the channel. 86 | public let copyright: String? 87 | 88 | /// Specifies a GIF, JPEG or PNG image that can be displayed with the channel. 89 | public let image: RSSImage? 90 | 91 | /// The author of the channel. 92 | public let author: Author? 93 | 94 | /// The categories associated with the channel. 95 | public let wpCategories: [WordPressElements.Category] 96 | 97 | /// The tags associated with the channel. 98 | public let wpTags: [WordPressElements.Tag] 99 | 100 | /// The base site URL of the channel. 101 | public let wpBaseSiteURL: URL? 102 | 103 | /// The base blog URL of the channel. 104 | public let wpBaseBlogURL: URL? 105 | 106 | /// Indicates whether the podcast is locked. 107 | public let podcastLocked: PodcastLocked? 108 | 109 | /// The fundings associated with the podcast. 110 | public let podcastFundings: [PodcastFunding] 111 | 112 | /// The people associated with the podcast. 113 | public let podcastPeople: [PodcastPerson] 114 | } 115 | 116 | extension RSSChannel { 117 | /// A struct representing an Atom category. 118 | /// A computed property that returns a ``SyndicationUpdate`` object 119 | /// based on the ``syUpdatePeriod`` and ``syUpdateFrequency`` properties. 120 | /// 121 | /// - Returns: A ``SyndicationUpdate`` object. 122 | /// - SeeAlso: ``EntryCategory`` 123 | public var syndication: SyndicationUpdate? { 124 | SyndicationUpdate( 125 | period: syUpdatePeriod, 126 | frequency: syUpdateFrequency?.value 127 | ) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Feeds/RSS/RSSFeed.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A struct representing an Atom category. 4 | /// RSS is a Web content syndication format. 5 | /// 6 | /// Its name is an acronym for Really Simple Syndication. 7 | /// RSS is dialect of XML. 8 | /// All RSS files must conform to the XML 1.0 specification, 9 | /// as published on the World Wide Web Consortium (W3C) website. 10 | /// At the top level, a RSS document is a element, 11 | /// with a mandatory attribute called version, 12 | /// that specifies the version of RSS that the document conforms to. 13 | /// If it conforms to this specification, 14 | /// the version attribute must be 2.0. 15 | /// For more details, check out the 16 | /// [W3 sepcifications.](https://validator.w3.org/feed/docs/rss2.html) 17 | /// - SeeAlso: ``EntryCategory`` 18 | public struct RSSFeed { 19 | /// Root Channel of hte RSS Feed 20 | public let channel: RSSChannel 21 | } 22 | 23 | extension RSSFeed: DecodableFeed { 24 | internal static let source: DecoderSetup = DecoderSource.xml 25 | public static let label: String = "RSS" 26 | 27 | public var youtubeChannelID: String? { 28 | nil 29 | } 30 | 31 | public var authors: [Author] { 32 | guard let author = channel.author else { 33 | return [] 34 | } 35 | return [author] 36 | } 37 | 38 | public var children: [Entryable] { 39 | channel.items 40 | } 41 | 42 | public var title: String { 43 | channel.title 44 | } 45 | 46 | public var siteURL: URL? { 47 | channel.link 48 | } 49 | 50 | public var summary: String? { 51 | channel.description 52 | } 53 | 54 | public var updated: Date? { 55 | channel.lastBuildDate 56 | } 57 | 58 | public var copyright: String? { 59 | channel.copyright 60 | } 61 | 62 | public var image: URL? { 63 | channel.image?.link 64 | } 65 | 66 | public var syndication: SyndicationUpdate? { 67 | channel.syndication 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Feeds/RSS/RSSImage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Represents a GIF, JPEG, or PNG image. 4 | public struct RSSImage: Codable { 5 | /// The URL of the image. 6 | public let url: URL 7 | 8 | /// The title or description of the image. 9 | /// 10 | /// This is used in the ``alt`` attribute of the HTML `` tag 11 | /// when the channel is rendered in HTML. 12 | public let title: String 13 | 14 | /// The URL of the site that the image links to. 15 | /// 16 | /// In practice, the image ``title`` and ``link`` should have 17 | /// the same value as the channel's ``title`` and ``link``. 18 | public let link: URL 19 | 20 | /// The width of the image in pixels. 21 | public let width: Int? 22 | 23 | /// The height of the image in pixels. 24 | public let height: Int? 25 | 26 | /// Additional description of the image. 27 | /// 28 | /// This text is included in the ``title`` attribute of the link 29 | /// formed around the image in the HTML rendering. 30 | public let description: String? 31 | } 32 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Feeds/RSS/RSSItem+Decodings.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XMLCoder 3 | 4 | extension RSSItem { 5 | // swiftlint:disable function_body_length 6 | /// A struct representing an Atom category. 7 | /// Initializes a new ``RSSItem`` by decoding data from a decoder. 8 | /// 9 | /// - Parameter decoder: The decoder to read data from. 10 | /// 11 | /// - Throws: An error if the decoding fails. 12 | /// - SeeAlso: ``EntryCategory`` 13 | public init(from decoder: Decoder) throws { 14 | let container = try decoder.container(keyedBy: CodingKeys.self) 15 | title = try container.decode(String.self, forKey: .title) 16 | link = try container.decodeIfPresent(URL.self, forKey: .link) 17 | description = try container.decodeIfPresent(CData.self, forKey: .description) 18 | guid = try container.decode(EntryID.self, forKey: .guid) 19 | pubDate = try container.decodeDateIfPresentAndValid(forKey: .pubDate) 20 | contentEncoded = try container.decodeIfPresent(CData.self, forKey: .contentEncoded) 21 | categoryTerms = try container.decode([RSSItemCategory].self, forKey: .categoryTerms) 22 | content = try container.decodeIfPresent(String.self, forKey: .content) 23 | itunesTitle = try container.decodeIfPresent(String.self, forKey: .itunesTitle) 24 | itunesEpisode = try container.decodeIfPresent( 25 | iTunesEpisode.self, forKey: .itunesEpisode 26 | ) 27 | itunesAuthor = try container.decodeIfPresent(String.self, forKey: .itunesAuthor) 28 | itunesSubtitle = try container.decodeIfPresent(String.self, forKey: .itunesSubtitle) 29 | itunesSummary = try container.decodeIfPresent(CData.self, forKey: .itunesSummary) 30 | itunesExplicit = try container.decodeIfPresent(String.self, forKey: .itunesExplicit) 31 | itunesDuration = try container.decodeIfPresent( 32 | iTunesDuration.self, forKey: .itunesDuration 33 | ) 34 | itunesImage = try container.decodeIfPresent(iTunesImage.self, forKey: .itunesImage) 35 | 36 | podcastPeople = try container.decodeIfPresent( 37 | [PodcastPerson].self, 38 | forKey: .podcastPeople 39 | ) ?? [] 40 | podcastTranscripts = try container.decodeIfPresent( 41 | [PodcastTranscript].self, 42 | forKey: .podcastTranscripts 43 | ) ?? [] 44 | podcastChapters = try container.decodeIfPresent( 45 | PodcastChapters.self, 46 | forKey: .podcastChapters 47 | ) 48 | podcastSoundbites = try container.decodeIfPresent( 49 | [PodcastSoundbite].self, 50 | forKey: .podcastSoundbites 51 | ) ?? [] 52 | 53 | podcastSeason = try container.decodeIfPresent( 54 | PodcastSeason.self, 55 | forKey: .podcastSeason 56 | ) 57 | 58 | enclosure = try container.decodeIfPresent(Enclosure.self, forKey: .enclosure) 59 | creators = try container.decode([String].self, forKey: .creators) 60 | 61 | mediaContent = 62 | try container.decodeIfPresent(AtomMedia.self, forKey: .mediaContent) 63 | mediaThumbnail = 64 | try container.decodeIfPresent(AtomMedia.self, forKey: .mediaThumbnail) 65 | 66 | wpPostID = try container.decodeIfPresent(Int.self, forKey: .wpPostID) 67 | wpPostDate = try container.decodeIfPresent(Date.self, forKey: .wpPostDate) 68 | let wpPostDateGMT = try container.decodeIfPresent( 69 | String.self, forKey: .wpPostDateGMT 70 | ) 71 | if let wpPostDateGMT = wpPostDateGMT { 72 | if wpPostDateGMT == "0000-00-00 00:00:00" { 73 | self.wpPostDateGMT = nil 74 | } else { 75 | self.wpPostDateGMT = try container.decode( 76 | Date.self, forKey: .wpPostDateGMT 77 | ) 78 | } 79 | } else { 80 | self.wpPostDateGMT = nil 81 | } 82 | 83 | wpModifiedDate = try container.decodeIfPresent( 84 | Date.self, forKey: .wpModifiedDate 85 | ) 86 | 87 | let wpModifiedDateGMT = try container.decodeIfPresent( 88 | String.self, forKey: .wpModifiedDateGMT 89 | ) 90 | if let wpModifiedDateGMT = wpModifiedDateGMT { 91 | if wpModifiedDateGMT == "0000-00-00 00:00:00" { 92 | self.wpModifiedDateGMT = nil 93 | } else { 94 | self.wpModifiedDateGMT = try container.decode( 95 | Date.self, forKey: .wpModifiedDateGMT 96 | ) 97 | } 98 | } else { 99 | self.wpModifiedDateGMT = nil 100 | } 101 | 102 | let wpAttachmentURLCDData = try container.decodeIfPresent( 103 | CData.self, 104 | forKey: .wpAttachmentURL 105 | ) 106 | wpAttachmentURL = wpAttachmentURLCDData.map { $0.value }.flatMap(URL.init(string:)) 107 | 108 | wpPostName = try container.decodeIfPresent(CData.self, forKey: .wpPostName) 109 | wpPostType = try container.decodeIfPresent(CData.self, forKey: .wpPostType) 110 | wpPostMeta = try container.decodeIfPresent( 111 | [WordPressElements.PostMeta].self, 112 | forKey: .wpPostMeta 113 | ) ?? [] 114 | wpCommentStatus = try container.decodeIfPresent(CData.self, forKey: .wpCommentStatus) 115 | wpPingStatus = try container.decodeIfPresent(CData.self, forKey: .wpPingStatus) 116 | wpStatus = try container.decodeIfPresent(CData.self, forKey: .wpStatus) 117 | wpPostParent = try container.decodeIfPresent(Int.self, forKey: .wpPostParent) 118 | wpMenuOrder = try container.decodeIfPresent(Int.self, forKey: .wpMenuOrder) 119 | wpIsSticky = try container.decodeIfPresent(Int.self, forKey: .wpIsSticky) 120 | wpPostPassword = try container.decodeIfPresent( 121 | CData.self, forKey: .wpPostPassword 122 | ) 123 | } 124 | 125 | // swiftlint:enable function_body_length 126 | } 127 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Feeds/RSS/RSSItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XMLCoder 3 | 4 | public struct RSSItem: Codable { 5 | public enum CodingKeys: String, CodingKey { 6 | case title 7 | case link 8 | case description 9 | case guid 10 | case pubDate 11 | case categoryTerms = "category" 12 | case enclosure 13 | case contentEncoded = "content:encoded" 14 | case content 15 | case itunesTitle = "itunes:title" 16 | case itunesEpisode = "itunes:episode" 17 | case itunesAuthor = "itunes:author" 18 | case itunesSubtitle = "itunes:subtitle" 19 | case itunesSummary = "itunes:summary" 20 | case itunesExplicit = "itunes:explicit" 21 | case podcastPeople = "podcast:person" 22 | case podcastTranscripts = "podcast:transcript" 23 | case podcastChapters = "podcast:chapters" 24 | case podcastSoundbites = "podcast:soundbite" 25 | case podcastSeason = "podcast:season" 26 | case itunesDuration = "itunes:duration" 27 | case itunesImage = "itunes:image" 28 | case creators = "dc:creator" 29 | 30 | case wpPostID = "wp:postId" 31 | case wpPostDate = "wp:postDate" 32 | case wpPostDateGMT = "wp:postDateGmt" 33 | case wpModifiedDate = "wp:postModified" 34 | case wpModifiedDateGMT = "wp:postModifiedGmt" 35 | case wpPostName = "wp:postName" 36 | case wpPostType = "wp:postType" 37 | case wpPostMeta = "wp:postmeta" 38 | case wpCommentStatus = "wp:commentStatus" 39 | case wpPingStatus = "wp:pingStatus" 40 | case wpAttachmentURL = "wp:attachmentUrl" 41 | 42 | case wpStatus = "wp:status" 43 | case wpPostParent = "wp:postParent" 44 | case wpMenuOrder = "wp:menuOrder" 45 | case wpIsSticky = "wp:isSticky" 46 | case wpPostPassword = "wp:postPassword" 47 | 48 | case mediaContent = "media:content" 49 | case mediaThumbnail = "media:thumbnail" 50 | } 51 | 52 | public let title: String 53 | public let link: URL? 54 | public let description: CData? 55 | public let guid: EntryID 56 | public let pubDate: Date? 57 | public let contentEncoded: CData? 58 | public let categoryTerms: [RSSItemCategory] 59 | public let content: String? 60 | public let itunesTitle: String? 61 | public let itunesEpisode: iTunesEpisode? 62 | public let itunesAuthor: String? 63 | public let itunesSubtitle: String? 64 | public let itunesSummary: CData? 65 | public let itunesExplicit: String? 66 | public let itunesDuration: iTunesDuration? 67 | public let itunesImage: iTunesImage? 68 | public let podcastPeople: [PodcastPerson] 69 | public let podcastTranscripts: [PodcastTranscript] 70 | public let podcastChapters: PodcastChapters? 71 | public let podcastSoundbites: [PodcastSoundbite] 72 | public let podcastSeason: PodcastSeason? 73 | public let enclosure: Enclosure? 74 | public let creators: [String] 75 | public let wpCommentStatus: CData? 76 | public let wpPingStatus: CData? 77 | public let wpStatus: CData? 78 | public let wpPostParent: Int? 79 | public let wpMenuOrder: Int? 80 | public let wpIsSticky: Int? 81 | public let wpPostPassword: CData? 82 | public let wpPostID: Int? 83 | public let wpPostDate: Date? 84 | public let wpPostDateGMT: Date? 85 | public let wpModifiedDate: Date? 86 | public let wpModifiedDateGMT: Date? 87 | public let wpPostName: CData? 88 | public let wpPostType: CData? 89 | public let wpPostMeta: [WordPressElements.PostMeta] 90 | public let wpAttachmentURL: URL? 91 | public let mediaContent: AtomMedia? 92 | public let mediaThumbnail: AtomMedia? 93 | } 94 | 95 | extension RSSItem: Entryable { 96 | public var categories: [EntryCategory] { 97 | categoryTerms 98 | } 99 | 100 | public var url: URL? { 101 | link 102 | } 103 | 104 | public var contentHtml: String? { 105 | contentEncoded?.value ?? content ?? description?.value 106 | } 107 | 108 | public var summary: String? { 109 | description?.value 110 | } 111 | 112 | public var authors: [Author] { 113 | let authors = creators.map(Author.init) 114 | guard authors.isEmpty else { 115 | return authors 116 | } 117 | guard let author = itunesAuthor.map(Author.init) else { 118 | return [] 119 | } 120 | return [author] 121 | } 122 | 123 | public var id: EntryID { 124 | guid 125 | } 126 | 127 | public var published: Date? { 128 | pubDate 129 | } 130 | 131 | public var media: MediaContent? { 132 | PodcastEpisodeProperties(rssItem: self).map(MediaContent.podcast) 133 | } 134 | 135 | public var imageURL: URL? { 136 | itunesImage?.href ?? 137 | mediaThumbnail?.url ?? 138 | mediaContent?.url 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Feeds/RSS/RSSItemCategory.swift: -------------------------------------------------------------------------------- 1 | /// A struct representing an Atom category. 2 | /// A struct representing a category for an RSS item. 3 | /// 4 | /// This struct conforms to the ``Codable`` and ``EntryCategory`` protocols. 5 | /// 6 | /// - Note: The ``CodingKeys`` enum is used to specify the coding keys for the struct. 7 | /// 8 | /// - SeeAlso: ``EntryCategory`` 9 | /// 10 | /// - Remark: This struct is ``public`` to allow access from other modules. 11 | /// 12 | /// - Warning: Do not modify the ``CodingKeys`` enum. 13 | /// 14 | /// - Version: 1.0 15 | /// - SeeAlso: ``EntryCategory`` 16 | public struct RSSItemCategory: Codable, EntryCategory { 17 | /// The coding keys for the struct. 18 | internal enum CodingKeys: String, CodingKey { 19 | case value = "#CDATA" 20 | case domain 21 | case nicename 22 | } 23 | 24 | /// The term of the category. 25 | public var term: String { 26 | value 27 | } 28 | 29 | /// The value of the category. 30 | public let value: String 31 | 32 | /// The domain of the category. 33 | public let domain: String? 34 | 35 | /// The nicename of the category. 36 | public let nicename: String? 37 | 38 | /// A struct representing an Atom category. 39 | /// Initializes a new instance of ``RSSItemCategory``. 40 | /// 41 | /// - Parameters: 42 | /// - value: The value of the category. 43 | /// - domain: The domain of the category. Default value is ``nil``. 44 | /// - nicename: The nicename of the category. Default value is ``nil``. 45 | /// 46 | /// - Returns: A new instance of ``RSSItemCategory``. 47 | /// - SeeAlso: ``EntryCategory`` 48 | public init(value: String, domain: String? = nil, nicename: String? = nil) { 49 | self.value = value 50 | self.domain = domain 51 | self.nicename = nicename 52 | } 53 | 54 | /// A struct representing an Atom category. 55 | /// Initializes a new instance of ``RSSItemCategory`` from a decoder. 56 | /// 57 | /// - Parameter decoder: The decoder to use for decoding. 58 | /// 59 | /// - Throws: An error if the decoding fails. 60 | /// 61 | /// - Returns: A new instance of ``RSSItemCategory``. 62 | /// - SeeAlso: ``EntryCategory`` 63 | public init(from decoder: Decoder) throws { 64 | let value: String 65 | let container: KeyedDecodingContainer? 66 | do { 67 | let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) 68 | value = try keyedContainer.decode(String.self, forKey: .value) 69 | container = keyedContainer 70 | } catch { 71 | let singleValueContainer = try decoder.singleValueContainer() 72 | value = try singleValueContainer.decode(String.self) 73 | container = nil 74 | } 75 | self.value = value 76 | domain = try container?.decodeIfPresent(String.self, forKey: .domain) 77 | nicename = try container?.decodeIfPresent(String.self, forKey: .nicename) 78 | } 79 | } 80 | 81 | extension RSSItemCategory: Equatable { 82 | /// A struct representing an Atom category. 83 | /// Checks if two ``RSSItemCategory`` instances are equal. 84 | /// 85 | /// - Parameters: 86 | /// - lhs: The left-hand side ``RSSItemCategory`` instance. 87 | /// - rhs: The right-hand side ``RSSItemCategory`` instance. 88 | /// 89 | /// - Returns: ``true`` if the instances are equal, otherwise ``false``. 90 | /// - SeeAlso: ``EntryCategory`` 91 | public static func == (lhs: RSSItemCategory, rhs: RSSItemCategory) -> Bool { 92 | lhs.value == rhs.value 93 | && lhs.domain == rhs.domain 94 | && lhs.nicename == rhs.nicename 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/MediaContent.swift: -------------------------------------------------------------------------------- 1 | /// A struct representing an Atom category. 2 | /// Represents different types of media content. 3 | /// 4 | /// - podcast: A podcast episode. 5 | /// - video: A video. 6 | /// - SeeAlso: ``EntryCategory`` 7 | public enum MediaContent { 8 | /// A podcast episode. 9 | case podcast(PodcastEpisode) 10 | 11 | /// A video. 12 | case video(Video) 13 | } 14 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/Podcast/PodcastChapters+MimeType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension PodcastChapters { 4 | /// A private enum representing known MIME types for podcast chapters. 5 | private enum KnownMimeType: String, Codable { 6 | case json = "application/json+chapters" 7 | 8 | /// Initializes a ``KnownMimeType`` from a case-insensitive string. 9 | init?(caseInsensitive: String) { 10 | self.init(rawValue: caseInsensitive) 11 | } 12 | 13 | /// Initializes a ``KnownMimeType`` from a ``MimeType``. 14 | init?(mimeType: MimeType) { 15 | switch mimeType { 16 | case .json: 17 | self = .json 18 | 19 | case .unknown: 20 | return nil 21 | } 22 | } 23 | } 24 | 25 | /// An enum representing the MIME type of podcast chapters. 26 | public enum MimeType: Codable, Equatable, RawRepresentable { 27 | case json 28 | case unknown(String) 29 | 30 | /// The raw value of the MIME type. 31 | public var rawValue: String { 32 | if let knownMimeType = KnownMimeType(mimeType: self) { 33 | return knownMimeType.rawValue 34 | } else if case let .unknown(string) = self { 35 | return string 36 | } else { 37 | fatalError( 38 | // swiftlint:disable:next line_length 39 | "Type attribute of with value: \(self) should either be ``KnownMimeType``, or unknown!" 40 | ) 41 | } 42 | } 43 | 44 | /// Initializes a ``MimeType`` from a raw value. 45 | public init?(rawValue: String) { 46 | self.init(caseInsensitive: rawValue) 47 | } 48 | 49 | /// Initializes a ``MimeType`` from a case-insensitive string. 50 | public init(caseInsensitive: String) { 51 | if let knownMimeType = KnownMimeType(caseInsensitive: caseInsensitive) { 52 | self = .init(knownMimeType: knownMimeType) 53 | } else { 54 | self = .unknown(caseInsensitive) 55 | } 56 | } 57 | 58 | /// Initializes a ``MimeType`` from a ``KnownMimeType``. 59 | private init(knownMimeType: KnownMimeType) { 60 | switch knownMimeType { 61 | case .json: 62 | self = .json 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/Podcast/PodcastChapters.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A struct representing chapters of a podcast. 4 | public struct PodcastChapters: Codable, Equatable { 5 | /// The coding keys for encoding and decoding. 6 | public enum CodingKeys: String, CodingKey { 7 | case url 8 | case type 9 | } 10 | 11 | /// The URL of the chapter file. 12 | public let url: URL 13 | 14 | /// The MIME type of the chapter file. 15 | public let type: MimeType 16 | } 17 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/Podcast/PodcastEpisode.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A struct representing properties of a podcast episode. 4 | internal struct PodcastEpisodeProperties: PodcastEpisode { 5 | /// The title of the episode. 6 | internal let title: String? 7 | 8 | /// The episode number. 9 | internal let episode: Int? 10 | 11 | /// The author of the episode. 12 | internal let author: String? 13 | 14 | /// The subtitle of the episode. 15 | internal let subtitle: String? 16 | 17 | /// A summary of the episode. 18 | internal let summary: String? 19 | 20 | /// Indicates if the episode contains explicit content. 21 | internal let explicit: String? 22 | 23 | /// The duration of the episode. 24 | internal let duration: TimeInterval? 25 | 26 | /// The image associated with the episode. 27 | internal let image: iTunesImage? 28 | 29 | /// The enclosure of the episode. 30 | internal let enclosure: Enclosure 31 | 32 | /// The people involved in the episode. 33 | internal let people: [PodcastPerson] 34 | 35 | /// A struct representing an Atom category. 36 | /// Initializes a ``PodcastEpisodeProperties`` instance from an ``RSSItem``. 37 | /// 38 | /// - Parameter rssItem: The ``RSSItem`` to extract the properties from. 39 | /// 40 | /// - Returns: An initialized ``PodcastEpisodeProperties`` instance, 41 | /// or ``nil`` if the ``enclosure`` property is missing. 42 | /// - SeeAlso: ``EntryCategory`` 43 | internal init?(rssItem: RSSItem) { 44 | guard let enclosure = rssItem.enclosure else { 45 | return nil 46 | } 47 | title = rssItem.itunesTitle 48 | episode = rssItem.itunesEpisode?.value 49 | author = rssItem.itunesAuthor 50 | subtitle = rssItem.itunesSubtitle 51 | summary = rssItem.itunesSummary?.value 52 | explicit = rssItem.itunesExplicit 53 | duration = rssItem.itunesDuration?.value 54 | image = rssItem.itunesImage 55 | self.enclosure = enclosure 56 | people = rssItem.podcastPeople 57 | } 58 | } 59 | 60 | /// A protocol representing a podcast episode. 61 | public protocol PodcastEpisode { 62 | /// The title of the episode. 63 | var title: String? { get } 64 | 65 | /// The episode number. 66 | var episode: Int? { get } 67 | 68 | /// The author of the episode. 69 | var author: String? { get } 70 | 71 | /// The subtitle of the episode. 72 | var subtitle: String? { get } 73 | 74 | /// A summary of the episode. 75 | var summary: String? { get } 76 | 77 | /// Indicates if the episode contains explicit content. 78 | var explicit: String? { get } 79 | 80 | /// The duration of the episode. 81 | var duration: TimeInterval? { get } 82 | 83 | /// The image associated with the episode. 84 | var image: iTunesImage? { get } 85 | 86 | /// The enclosure of the episode. 87 | var enclosure: Enclosure { get } 88 | 89 | /// The people involved in the episode. 90 | var people: [PodcastPerson] { get } 91 | } 92 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/Podcast/PodcastFunding.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A struct representing funding information for a podcast. 4 | public struct PodcastFunding: Codable, Equatable { 5 | /// The coding keys used for encoding and decoding. 6 | public enum CodingKeys: String, CodingKey { 7 | case url 8 | case description = "" 9 | } 10 | 11 | /// The URL for the funding source. 12 | public let url: URL 13 | 14 | /// A description of the funding source. 15 | public let description: String? 16 | } 17 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/Podcast/PodcastLocation+GeoURI.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension PodcastLocation { 4 | /// A ``struct`` representing a geographic URI for a podcast location. 5 | public struct GeoURI: Codable, Equatable, LosslessStringConvertible { 6 | /// The latitude coordinate. 7 | public let latitude: Double 8 | 9 | /// The longitude coordinate. 10 | public let longitude: Double 11 | 12 | /// The altitude coordinate, if available. 13 | public let altitude: Double? 14 | 15 | /// The accuracy of the coordinates, if available. 16 | public let accuracy: Double? 17 | 18 | /// A string representation of the geographic URI. 19 | public var description: String { 20 | var description = "geo:\(latitude),\(longitude)" 21 | 22 | if let altitude = altitude { 23 | description += ",\(altitude)" 24 | } 25 | 26 | if let accuracy = accuracy { 27 | description += ";u=\(accuracy)" 28 | } 29 | 30 | return description 31 | } 32 | 33 | /// Initializes a ``GeoURI`` instance with the specified coordinates. 34 | /// 35 | /// - Parameters: 36 | /// - latitude: The latitude coordinate. 37 | /// - longitude: The longitude coordinate. 38 | /// - altitude: The altitude coordinate, if available. 39 | /// - accuracy: The accuracy of the coordinates, if available. 40 | public init( 41 | latitude: Double, 42 | longitude: Double, 43 | altitude: Double? = nil, 44 | accuracy: Double? = nil 45 | ) { 46 | self.latitude = latitude 47 | self.longitude = longitude 48 | self.altitude = altitude 49 | self.accuracy = accuracy 50 | } 51 | 52 | /// Initializes a ``GeoURI`` instance from a string representation. 53 | /// 54 | /// - Parameter description: The string representation of the geographic URI. 55 | public init?(_ description: String) { 56 | try? self.init(singleValue: description) 57 | } 58 | 59 | // swiftlint:disable function_body_length 60 | /// Initializes a ``GeoURI`` instance from a single value string. 61 | /// 62 | /// - Parameter singleValue: The single value string representing the geographic URI. 63 | /// - Throws: A ``DecodingError`` if the single value string is invalid. 64 | public init(singleValue: String) throws { 65 | let pathComponents = try Self.pathComponents(from: singleValue) 66 | 67 | guard 68 | let geoCoords = pathComponents[safe: 0]?.split(separator: ","), 69 | let latitude = geoCoords[safe: 0]?.asDouble(), 70 | let longitude = geoCoords[safe: 1]?.asDouble() 71 | else { 72 | throw DecodingError.dataCorrupted( 73 | codingKey: PodcastLocation.CodingKeys.geo, 74 | debugDescription: "Invalid coordinates for geo attribute: \(singleValue)" 75 | ) 76 | } 77 | 78 | let altitude = geoCoords[safe: 2]?.asDouble() 79 | 80 | let accuracy = pathComponents[safe: 1]? 81 | .split(separator: "=")[safe: 1]? 82 | .asDouble() 83 | 84 | self.init( 85 | latitude: latitude, 86 | longitude: longitude, 87 | altitude: altitude, 88 | accuracy: accuracy 89 | ) 90 | } 91 | 92 | // swiftlint:enable function_body_length 93 | 94 | /// Initializes a ``GeoURI`` instance from a decoder. 95 | /// 96 | /// - Parameter decoder: The decoder to read data from. 97 | /// - Throws: A ``DecodingError`` if the decoding process fails. 98 | public init(from decoder: Decoder) throws { 99 | let container = try decoder.singleValueContainer() 100 | let singleValue = try container.decode(String.self) 101 | 102 | try self.init(singleValue: singleValue) 103 | } 104 | 105 | private static func pathComponents(from string: String) throws -> [Substring] { 106 | let components = string.split(separator: ":") 107 | 108 | guard 109 | components[safe: 0] == "geo" else { 110 | throw DecodingError.dataCorrupted( 111 | codingKey: PodcastLocation.CodingKeys.geo, 112 | debugDescription: "Invalid prefix for geo attribute: \(string)" 113 | ) 114 | } 115 | guard let geoPath = components[safe: 1] else { 116 | throw DecodingError.dataCorrupted( 117 | codingKey: PodcastLocation.CodingKeys.geo, 118 | debugDescription: "Invalid path for geo attribute: \(string)" 119 | ) 120 | } 121 | 122 | return geoPath.split(separator: ";") 123 | } 124 | 125 | /// Encodes the ``GeoURI`` instance into the given encoder. 126 | /// 127 | /// - Parameter encoder: The encoder to write data to. 128 | /// - Throws: An error if the encoding process fails. 129 | public func encode(to encoder: Encoder) throws { 130 | var container = encoder.singleValueContainer() 131 | try container.encode(description) 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/Podcast/PodcastLocation+OsmQuery.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension PodcastLocation { 4 | /// Represents a query for OpenStreetMap (OSM) data. 5 | public struct OsmQuery: Codable, Equatable { 6 | // swiftlint:disable nesting 7 | /// The type of OSM element. 8 | public enum OsmType: String, Codable, CaseIterable { 9 | case node = "N" 10 | case way = "W" 11 | case relation = "R" 12 | } 13 | 14 | // swiftlint:enable nesting 15 | 16 | /// The ID of the OSM element. 17 | public let id: Int 18 | 19 | /// The type of the OSM element. 20 | public let type: OsmType 21 | 22 | /// The revision number of the OSM element. 23 | public let revision: Int? 24 | 25 | /// Initializes an ``OsmQuery`` instance from a decoder. 26 | /// 27 | /// - Parameter decoder: The decoder to read data from. 28 | /// - Throws: `DecodingError.dataCorrupted` if the data is invalid. 29 | public init(from decoder: Decoder) throws { 30 | let container = try decoder.singleValueContainer() 31 | 32 | var osmStr = try container.decode(String.self) 33 | 34 | guard let osmType = osmStr.removeFirst().asOsmType() else { 35 | throw DecodingError.dataCorrupted( 36 | codingKey: PodcastLocation.CodingKeys.osmQuery, 37 | debugDescription: "Invalid type for osm attribute: \(osmStr)" 38 | ) 39 | } 40 | guard let osmID = osmStr.split(separator: "#")[safe: 0]?.asExactInt() else { 41 | throw DecodingError.dataCorrupted( 42 | codingKey: PodcastLocation.CodingKeys.osmQuery, 43 | debugDescription: "Invalid id of type Int for osm attribute: \(osmStr)" 44 | ) 45 | } 46 | let osmRevision = osmStr.split(separator: "#")[safe: 1]?.asInt() 47 | 48 | id = osmID 49 | type = osmType 50 | revision = osmRevision 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/Podcast/PodcastLocation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A struct representing the location of a podcast. 4 | public struct PodcastLocation: Codable, Equatable { 5 | /// The geographic coordinates of the location. 6 | internal enum CodingKeys: String, CodingKey { 7 | case geo 8 | case osmQuery = "osm" 9 | 10 | case name = "" 11 | } 12 | 13 | /// The geographic coordinates of the location. 14 | public let geo: GeoURI? 15 | 16 | /// The OpenStreetMap query for the location. 17 | public let osmQuery: OsmQuery? 18 | 19 | /// The name of the location. 20 | public let name: String 21 | } 22 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/Podcast/PodcastLocked.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A struct representing a locked podcast. 4 | public struct PodcastLocked: Codable, Equatable { 5 | /// Coding keys for encoding and decoding. 6 | public enum CodingKeys: String, CodingKey { 7 | case owner 8 | case isLocked = "" 9 | } 10 | 11 | /// The owner of the podcast. 12 | public let owner: String? 13 | 14 | /// Indicates whether the podcast is locked. 15 | public let isLocked: Bool 16 | 17 | /// Initializes a new instance of ``PodcastLocked`` from a decoder. 18 | /// 19 | /// - Parameter decoder: The decoder to read data from. 20 | /// - Throws: An error if the decoding process fails. 21 | public init(from decoder: Decoder) throws { 22 | let container = try decoder.container(keyedBy: CodingKeys.self) 23 | owner = try container.decodeIfPresent(String.self, forKey: .owner) 24 | isLocked = try container.decode(String.self, forKey: .isLocked).lowercased() == "yes" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/Podcast/PodcastPerson+Role.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension PodcastPerson { 4 | /// A private enum representing known roles for a podcast person. 5 | private enum KnownRole: String { 6 | case guest 7 | case host 8 | case editor 9 | case writer 10 | case designer 11 | case composer 12 | case producer 13 | 14 | /// Initializes a ``KnownRole`` with a case-insensitive string. 15 | init?(caseInsensitive: String) { 16 | self.init(rawValue: caseInsensitive.lowercased()) 17 | } 18 | 19 | // swiftlint:disable function_body_length cyclomatic_complexity 20 | /// Initializes a ``KnownRole`` with a ``Role`` value. 21 | init?(role: Role) { 22 | switch role { 23 | case .guest: 24 | self = .guest 25 | 26 | case .host: 27 | self = .host 28 | 29 | case .editor: 30 | self = .editor 31 | 32 | case .writer: 33 | self = .writer 34 | 35 | case .designer: 36 | self = .designer 37 | 38 | case .composer: 39 | self = .composer 40 | 41 | case .producer: 42 | self = .producer 43 | 44 | case .unknown: 45 | return nil 46 | } 47 | } 48 | } 49 | 50 | // swiftlint:enable function_body_length cyclomatic_complexity 51 | 52 | /// An enum representing the role of a podcast person. 53 | public enum Role: Codable, Equatable, RawRepresentable { 54 | case guest 55 | case host 56 | case editor 57 | case writer 58 | case designer 59 | case composer 60 | case producer 61 | case unknown(String) 62 | 63 | /// The raw value of the role. 64 | public var rawValue: String { 65 | if let knownRole = KnownRole(role: self) { 66 | return knownRole.rawValue 67 | } else if case let .unknown(string) = self { 68 | return string 69 | } else { 70 | fatalError( 71 | // swiftlint:disable:next line_length 72 | "Role attribute of with value: \(self) should either be a ``KnownRole``, or unknown!" 73 | ) 74 | } 75 | } 76 | 77 | /// Initializes a ``Role`` with a raw value. 78 | public init?(rawValue: String) { 79 | self.init(caseInsensitive: rawValue) 80 | } 81 | 82 | /// Initializes a ``Role`` with a case-insensitive string. 83 | public init(caseInsensitive: String) { 84 | if let knownRole = KnownRole(caseInsensitive: caseInsensitive) { 85 | self = .init(knownRole: knownRole) 86 | } else { 87 | self = .unknown(caseInsensitive) 88 | } 89 | } 90 | 91 | // swiftlint:disable:next cyclomatic_complexity 92 | private init(knownRole: KnownRole) { 93 | switch knownRole { 94 | case .guest: 95 | self = .guest 96 | 97 | case .host: 98 | self = .host 99 | 100 | case .editor: 101 | self = .editor 102 | 103 | case .writer: 104 | self = .writer 105 | 106 | case .designer: 107 | self = .designer 108 | 109 | case .composer: 110 | self = .composer 111 | 112 | case .producer: 113 | self = .producer 114 | } 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/Podcast/PodcastPerson.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A struct representing a person associated with a podcast. 4 | public struct PodcastPerson: Codable, Equatable { 5 | /// The role of the person. 6 | public enum CodingKeys: String, CodingKey { 7 | case role 8 | case group 9 | case href 10 | case img 11 | case fullname = "" 12 | } 13 | 14 | /// The role of the person. 15 | public let role: Role? 16 | 17 | /// The group the person belongs to. 18 | public let group: String? 19 | 20 | /// The URL associated with the person. 21 | public let href: URL? 22 | 23 | /// The URL of the person's image. 24 | public let img: URL? 25 | 26 | /// The full name of the person. 27 | public let fullname: String 28 | 29 | /// Initializes a new instance of ``PodcastPerson`` 30 | /// by decoding data from the given decoder. 31 | /// 32 | /// - Parameter decoder: The decoder to read data from. 33 | /// - Throws: An error if the decoding process fails. 34 | public init(from decoder: Decoder) throws { 35 | let container = try decoder.container(keyedBy: CodingKeys.self) 36 | role = try container.decodeIfPresent(Role.self, forKey: .role) 37 | group = try container.decodeIfPresent(String.self, forKey: .group) 38 | fullname = try container.decode(String.self, forKey: .fullname) 39 | 40 | let hrefUrl = try container.decodeIfPresent(String.self, forKey: .href) ?? "" 41 | href = hrefUrl.isEmpty ? nil : URL(string: hrefUrl) 42 | 43 | let imgUrl = try container.decodeIfPresent(String.self, forKey: .img) ?? "" 44 | img = imgUrl.isEmpty ? nil : URL(string: imgUrl) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/Podcast/PodcastSeason.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A struct representing a season of a podcast. 4 | public struct PodcastSeason: Codable, Equatable { 5 | /// The coding keys for the ``PodcastSeason`` struct. 6 | public enum CodingKeys: String, CodingKey { 7 | case name 8 | case number = "" 9 | } 10 | 11 | /// The name of the season. 12 | public let name: String? 13 | 14 | /// The number of the season. 15 | public let number: Int 16 | } 17 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/Podcast/PodcastSoundbite.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A struct representing a soundbite from a podcast. 4 | public struct PodcastSoundbite: Codable, Equatable { 5 | /// The coding keys used for encoding and decoding. 6 | public enum CodingKeys: String, CodingKey { 7 | case startTime 8 | case duration 9 | 10 | case title = "" 11 | } 12 | 13 | /// The start time of the soundbite. 14 | public let startTime: TimeInterval 15 | 16 | /// The duration of the soundbite. 17 | public let duration: TimeInterval 18 | 19 | /// The title of the soundbite. 20 | public let title: String? 21 | } 22 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/Podcast/PodcastTranscript+MimeType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension PodcastTranscript { 4 | /// A private enum representing known MIME types for the transcript. 5 | private enum KnownMimeType: String, Codable { 6 | case plain = "text/plain" 7 | case html = "text/html" 8 | case srt = "text/srt" 9 | case vtt = "text/vtt" 10 | case json = "application/json" 11 | case subrip = "application/x-subrip" 12 | 13 | /// Initializes a ``KnownMimeType`` with a case-insensitive string. 14 | init?(caseInsensitive: String) { 15 | self.init(rawValue: caseInsensitive) 16 | } 17 | 18 | // swiftlint:disable cyclomatic_complexity 19 | /// Initializes a ``KnownMimeType`` with a ``MimeType``. 20 | init?(mimeType: MimeType) { 21 | switch mimeType { 22 | case .plain: 23 | self = .plain 24 | 25 | case .html: 26 | self = .html 27 | 28 | case .srt: 29 | self = .srt 30 | 31 | case .vtt: 32 | self = .vtt 33 | 34 | case .json: 35 | self = .json 36 | 37 | case .subrip: 38 | self = .subrip 39 | 40 | case .unknown: 41 | return nil 42 | } 43 | } 44 | 45 | // swiftlint:enable cyclomatic_complexity 46 | } 47 | 48 | /// An enum representing the MIME type of the transcript. 49 | public enum MimeType: Codable, Equatable, RawRepresentable { 50 | case plain 51 | case html 52 | case srt 53 | case vtt 54 | case json 55 | case subrip 56 | case unknown(String) 57 | 58 | /// The raw value of the MIME type. 59 | public var rawValue: String { 60 | if let knownMimeType = KnownMimeType(mimeType: self) { 61 | return knownMimeType.rawValue 62 | } else if case let .unknown(string) = self { 63 | return string 64 | } else { 65 | fatalError( 66 | // swiftlint:disable:next line_length 67 | "Type attribute of with value: \(self) should either be a ``KnownMimeType``, or unknown!" 68 | ) 69 | } 70 | } 71 | 72 | /// Initializes a ``MimeType`` with a raw value. 73 | public init?(rawValue: String) { 74 | self.init(caseInsensitive: rawValue) 75 | } 76 | 77 | /// Initializes a ``MimeType`` with a case-insensitive string. 78 | public init(caseInsensitive: String) { 79 | if let knownMimeType = KnownMimeType(caseInsensitive: caseInsensitive) { 80 | self = .init(knownMimeType: knownMimeType) 81 | } else { 82 | self = .unknown(caseInsensitive) 83 | } 84 | } 85 | 86 | /// Initializes a ``MimeType`` with a ``KnownMimeType``. 87 | private init(knownMimeType: KnownMimeType) { 88 | switch knownMimeType { 89 | case .plain: 90 | self = .plain 91 | 92 | case .html: 93 | self = .html 94 | 95 | case .srt: 96 | self = .srt 97 | 98 | case .vtt: 99 | self = .vtt 100 | 101 | case .json: 102 | self = .json 103 | 104 | case .subrip: 105 | self = .subrip 106 | } 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/Podcast/PodcastTranscript.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A struct representing a podcast transcript. 4 | public struct PodcastTranscript: Codable, Equatable { 5 | /// The coding keys for the podcast transcript. 6 | public enum CodingKeys: String, CodingKey { 7 | case url 8 | case type 9 | case language 10 | case rel 11 | } 12 | 13 | /// The relationship between the podcast transcript and the podcast. 14 | public enum Relationship: String, Codable { 15 | case captions 16 | } 17 | 18 | /// The URL of the podcast transcript. 19 | public let url: URL 20 | 21 | /// The MIME type of the podcast transcript. 22 | public let type: MimeType 23 | 24 | /// The language of the podcast transcript. 25 | public let language: String? 26 | 27 | /// The relationship of the podcast transcript. 28 | public let rel: Relationship? 29 | } 30 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/Video.swift: -------------------------------------------------------------------------------- 1 | /// A struct representing an Atom category. 2 | /// An enumeration representing different types of videos. 3 | /// - SeeAlso: ``EntryCategory`` 4 | public enum Video { 5 | /// A video from YouTube. 6 | /// - Parameters: 7 | /// - id: The ID of the YouTube video. 8 | case youtube(YouTubeID) 9 | } 10 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/Wordpress/WPCategory.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A typealias for the `WordPressElements.Category` type. 4 | public typealias WPCategory = WordPressElements.Category 5 | 6 | // swiftlint:disable nesting 7 | extension WordPressElements { 8 | /// A struct representing a category in WordPress. 9 | public struct Category: Codable { 10 | /// The coding keys for the ``Category`` struct. 11 | internal enum CodingKeys: String, CodingKey { 12 | case termID = "wp:termId" 13 | case niceName = "wp:categoryNicename" 14 | case parent = "wp:categoryParent" 15 | case name = "wp:catName" 16 | } 17 | 18 | /// The unique identifier of the category. 19 | public let termID: Int 20 | 21 | /// The nice name of the category. 22 | public let niceName: CData 23 | 24 | /// The parent category of the category. 25 | public let parent: CData 26 | 27 | /// The name of the category. 28 | public let name: String 29 | 30 | /// A struct representing an Atom category. 31 | /// Initializes a new ``Category`` instance. 32 | /// 33 | /// - Parameters: 34 | /// - termID: The unique identifier of the category. 35 | /// - niceName: The nice name of the category. 36 | /// - parent: The parent category of the category. 37 | /// - name: The name of the category. 38 | /// - SeeAlso: ``EntryCategory`` 39 | public init(termID: Int, niceName: CData, parent: CData, name: String) { 40 | self.termID = termID 41 | self.niceName = niceName 42 | self.parent = parent 43 | self.name = name 44 | } 45 | } 46 | } 47 | 48 | extension WordPressElements.Category: Equatable { 49 | /// Checks if two ``Category`` instances are equal. 50 | public static func == ( 51 | lhs: WordPressElements.Category, 52 | rhs: WordPressElements.Category 53 | ) -> Bool { 54 | lhs.termID == rhs.termID 55 | && lhs.niceName == rhs.niceName 56 | && lhs.parent == rhs.parent 57 | && lhs.name == rhs.name 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/Wordpress/WPPostMeta.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A typealias for `WordPressElements.Category`. 4 | public typealias WPPostMeta = WordPressElements.Category 5 | 6 | // swiftlint:disable nesting 7 | extension WordPressElements { 8 | /// A struct representing metadata for a WordPress post. 9 | public struct PostMeta: Codable { 10 | /// The coding keys for encoding and decoding. 11 | internal enum CodingKeys: String, CodingKey { 12 | case key = "wp:metaKey" 13 | case value = "wp:metaValue" 14 | } 15 | 16 | /// The key of the metadata. 17 | public let key: CData 18 | 19 | /// The value of the metadata. 20 | public let value: CData 21 | 22 | /// A struct representing an Atom category. 23 | /// Initializes a new ``PostMeta`` instance. 24 | /// 25 | /// - Parameters: 26 | /// - key: The key of the metadata. 27 | /// - value: The value of the metadata. 28 | /// - SeeAlso: ``EntryCategory`` 29 | public init(key: String, value: String) { 30 | self.key = .init(stringLiteral: key) 31 | self.value = .init(stringLiteral: value) 32 | } 33 | } 34 | } 35 | 36 | extension WordPressElements.PostMeta: Equatable { 37 | /// A struct representing an Atom category. 38 | /// Checks if two ``PostMeta`` instances are equal. 39 | /// 40 | /// - Parameters: 41 | /// - lhs: The left-hand side ``PostMeta`` instance. 42 | /// - rhs: The right-hand side ``PostMeta`` instance. 43 | /// 44 | /// - Returns: ``true`` if the two instances are equal, ``false`` otherwise. 45 | /// - SeeAlso: ``EntryCategory`` 46 | public static func == ( 47 | lhs: WordPressElements.PostMeta, 48 | rhs: WordPressElements.PostMeta 49 | ) -> Bool { 50 | lhs.key == rhs.key 51 | && lhs.value == rhs.value 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/Wordpress/WPTag.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A typealias for `WordPressElements.Tag` 4 | public typealias WPTag = WordPressElements.Tag 5 | 6 | // swiftlint:disable nesting 7 | extension WordPressElements { 8 | /// A struct representing a tag in WordPress. 9 | public struct Tag: Codable { 10 | /// The term ID of the tag. 11 | internal enum CodingKeys: String, CodingKey { 12 | case termID = "wp:termId" 13 | case slug = "wp:tagSlug" 14 | case name = "wp:tagName" 15 | } 16 | 17 | /// The term ID of the tag. 18 | public let termID: Int 19 | 20 | /// The slug of the tag. 21 | public let slug: CData 22 | 23 | /// The name of the tag. 24 | public let name: CData 25 | 26 | /// A struct representing an Atom category. 27 | /// Initializes a new ``Tag`` instance. 28 | /// 29 | /// - Parameters: 30 | /// - termID: The term ID of the tag. 31 | /// - slug: The slug of the tag. 32 | /// - name: The name of the tag. 33 | /// - SeeAlso: ``EntryCategory`` 34 | public init(termID: Int, slug: CData, name: CData) { 35 | self.termID = termID 36 | self.slug = slug 37 | self.name = name 38 | } 39 | } 40 | } 41 | 42 | extension WordPressElements.Tag: Equatable { 43 | /// Checks if two ``Tag`` instances are equal. 44 | public static func == (lhs: WordPressElements.Tag, rhs: WordPressElements.Tag) -> Bool { 45 | lhs.termID == rhs.termID 46 | && lhs.slug == rhs.slug 47 | && lhs.name == rhs.name 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/Wordpress/WordPressPost+RSSItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension WordPressPost { 4 | // swiftlint:disable cyclomatic_complexity function_body_length 5 | /// A struct representing an Atom category. 6 | /// Initializes a ``WordPressPost`` instance from an ``RSSItem``. 7 | /// 8 | /// - Parameter item: The ``RSSItem`` to initialize from. 9 | /// 10 | /// - Throws: `WordPressError.missingField` if any required field is missing. 11 | /// 12 | /// - Note: This initializer is marked as ``public`` to allow external usage. 13 | /// 14 | /// - SeeAlso: ``EntryCategory`` 15 | public init(item: RSSItem) throws { 16 | guard let name = item.wpPostName else { 17 | throw WordPressError.missingField(.name) 18 | } 19 | guard let type = item.wpPostType else { 20 | throw WordPressError.missingField(.type) 21 | } 22 | guard let creator = item.creators.first else { 23 | throw WordPressError.missingField(.creator) 24 | } 25 | guard let body = item.contentEncoded else { 26 | throw WordPressError.missingField(.body) 27 | } 28 | guard let status = item.wpStatus else { 29 | throw WordPressError.missingField(.status) 30 | } 31 | guard let commentStatus = item.wpCommentStatus else { 32 | throw WordPressError.missingField(.commentStatus) 33 | } 34 | guard let pingStatus = item.wpPingStatus else { 35 | throw WordPressError.missingField(.pingStatus) 36 | } 37 | guard let parentID = item.wpPostParent else { 38 | throw WordPressError.missingField(.parentID) 39 | } 40 | guard let menuOrder = item.wpMenuOrder else { 41 | throw WordPressError.missingField(.menuOrder) 42 | } 43 | guard let id = item.wpPostID else { 44 | throw WordPressError.missingField(.id) 45 | } 46 | guard let isSticky = item.wpIsSticky else { 47 | throw WordPressError.missingField(.isSticky) 48 | } 49 | guard let postDate = item.wpPostDate else { 50 | throw WordPressError.missingField(.postDate) 51 | } 52 | guard let modifiedDate = item.wpModifiedDate else { 53 | throw WordPressError.missingField(.modifiedDate) 54 | } 55 | guard let link = item.link else { 56 | throw WordPressError.missingField(.link) 57 | } 58 | 59 | let title = item.title 60 | let categoryTerms = item.categoryTerms 61 | let meta = item.wpPostMeta 62 | let pubDate = item.pubDate 63 | 64 | let categoryDictionary = Dictionary( 65 | grouping: categoryTerms) { 66 | $0.domain 67 | } 68 | 69 | modifiedDateGMT = item.wpModifiedDateGMT 70 | self.name = name.value 71 | self.title = title 72 | self.type = type.value 73 | self.link = link 74 | self.pubDate = pubDate 75 | self.creator = creator 76 | self.body = body.value 77 | tags = categoryDictionary["post_tag", default: []].map { $0.value } 78 | categories = categoryDictionary["category", default: []].map { $0.value } 79 | self.meta = Dictionary(grouping: meta) { $0.key.value } 80 | .compactMapValues { $0.last?.value.value } 81 | self.status = status.value 82 | self.commentStatus = commentStatus.value 83 | self.pingStatus = pingStatus.value 84 | self.parentID = parentID 85 | self.menuOrder = menuOrder 86 | self.id = id 87 | self.isSticky = (isSticky != 0) 88 | self.postDate = postDate 89 | postDateGMT = item.wpPostDateGMT 90 | self.modifiedDate = modifiedDate 91 | attachmentURL = item.wpAttachmentURL 92 | } 93 | 94 | // swiftlint:enable cyclomatic_complexity function_body_length 95 | } 96 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/Wordpress/WordPressPost.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A namespace for WordPress related elements. 4 | public enum WordPressElements {} 5 | 6 | /// An error type representing a missing field in a WordPress post. 7 | public enum WordPressError: Error, Equatable { 8 | case missingField(WordPressPost.Field) 9 | } 10 | 11 | /// A struct representing a WordPress post. 12 | public struct WordPressPost { 13 | /// The type of the post. 14 | public typealias PostType = String 15 | 16 | /// The comment status of the post. 17 | public typealias CommentStatus = String 18 | 19 | /// The ping status of the post. 20 | public typealias PingStatus = String 21 | 22 | /// The status of the post. 23 | public typealias Status = String 24 | 25 | /// An enum representing the fields of a WordPress post. 26 | public enum Field: Equatable { 27 | case name 28 | case title 29 | case type 30 | case link 31 | case pubDate 32 | case creator 33 | case body 34 | case tags 35 | case categories 36 | case meta 37 | case status 38 | case commentStatus 39 | case pingStatus 40 | case parentID 41 | case menuOrder 42 | case id 43 | case isSticky 44 | case postDate 45 | case postDateGMT 46 | case modifiedDate 47 | case modifiedDateGMT 48 | } 49 | 50 | /// The name of the post. 51 | public let name: String 52 | 53 | /// The title of the post. 54 | public let title: String 55 | 56 | /// The type of the post. 57 | public let type: PostType 58 | 59 | /// The link of the post. 60 | public let link: URL 61 | 62 | /// The publication date of the post. 63 | public let pubDate: Date? 64 | 65 | /// The creator of the post. 66 | public let creator: String 67 | 68 | /// The body of the post. 69 | public let body: String 70 | 71 | /// The tags of the post. 72 | public let tags: [String] 73 | 74 | /// The categories of the post. 75 | public let categories: [String] 76 | 77 | /// The meta data of the post. 78 | public let meta: [String: String] 79 | 80 | /// The status of the post. 81 | public let status: Status 82 | 83 | /// The comment status of the post. 84 | public let commentStatus: CommentStatus 85 | 86 | /// The ping status of the post. 87 | public let pingStatus: PingStatus 88 | 89 | /// The parent ID of the post. 90 | public let parentID: Int? 91 | 92 | /// The menu order of the post. 93 | public let menuOrder: Int? 94 | 95 | /// The ID of the post. 96 | public let id: Int 97 | 98 | /// A boolean indicating if the post is sticky. 99 | public let isSticky: Bool 100 | 101 | /// The post date of the post. 102 | public let postDate: Date 103 | 104 | /// The post date in GMT of the post. 105 | public let postDateGMT: Date? 106 | 107 | /// The modified date of the post. 108 | public let modifiedDate: Date 109 | 110 | /// The modified date in GMT of the post. 111 | public let modifiedDateGMT: Date? 112 | 113 | /// The attachment URL of the post. 114 | public let attachmentURL: URL? 115 | } 116 | 117 | extension WordPressPost: Hashable { 118 | public static func == (lhs: WordPressPost, rhs: WordPressPost) -> Bool { 119 | lhs.id == rhs.id 120 | } 121 | 122 | public func hash(into hasher: inout Hasher) { 123 | hasher.combine(id) 124 | } 125 | } 126 | 127 | extension Entryable { 128 | /// Returns a WordPress post if the entry is an RSS item. 129 | public var wpPost: WordPressPost? { 130 | guard let rssItem = self as? RSSItem else { 131 | return nil 132 | } 133 | 134 | return try? WordPressPost(item: rssItem) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/YouTube/YouTubeID.swift: -------------------------------------------------------------------------------- 1 | /// A struct representing an Atom category. 2 | /// A struct representing the properties of a YouTube ID. 3 | /// 4 | /// - Note: This struct conforms to the ``YouTubeID`` protocol. 5 | /// 6 | /// - SeeAlso: ``YouTubeID`` 7 | /// 8 | /// - Important: This struct is internal. 9 | /// 10 | /// - Parameters: 11 | /// - videoID: The YouTube video ID. 12 | /// - channelID: The YouTube channel ID. 13 | /// - SeeAlso: ``EntryCategory`` 14 | internal struct YouTubeIDProperties: YouTubeID { 15 | internal let videoID: String 16 | internal let channelID: String 17 | 18 | /// A struct representing an Atom category. 19 | /// Initializes a ``YouTubeIDProperties`` instance with the given AtomEntry. 20 | /// 21 | /// - Parameters: 22 | /// - entry: The AtomEntry containing the YouTube ID properties. 23 | /// 24 | /// - Returns: A new ``YouTubeIDProperties`` instance, 25 | /// or ``nil`` if the required properties are missing. 26 | /// - SeeAlso: ``EntryCategory`` 27 | internal init?(entry: AtomEntry) { 28 | guard 29 | let channelID = entry.youtubeChannelID, 30 | let videoID = entry.youtubeVideoID else { 31 | return nil 32 | } 33 | self.channelID = channelID 34 | self.videoID = videoID 35 | } 36 | } 37 | 38 | /// A struct representing an Atom category. 39 | /// A protocol abstracting the ID properties of a YouTube RSS Feed. 40 | /// 41 | /// - Note: This protocol is public. 42 | /// 43 | /// - SeeAlso: ``YouTubeIDProperties`` 44 | /// 45 | /// - Important: This protocol is specific to YouTube. 46 | /// 47 | /// - Requires: Conforming types must provide a ``videoID`` and a ``channelID``. 48 | /// - SeeAlso: ``EntryCategory`` 49 | public protocol YouTubeID { 50 | /// The YouTube video ID. 51 | var videoID: String { get } 52 | 53 | /// The YouTube channel ID. 54 | var channelID: String { get } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/iTunes/iTunesDuration.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A struct representing the duration of an iTunes track. 4 | public struct iTunesDuration: Codable, ExpressibleByFloatLiteral { 5 | /// The type used to represent a floating-point literal. 6 | public typealias FloatLiteralType = TimeInterval 7 | 8 | /// The value of the duration in seconds. 9 | public let value: TimeInterval 10 | 11 | /// Creates a new instance with the specified floating-point literal value. 12 | /// 13 | /// - Parameter value: The value of the duration in seconds. 14 | public init(floatLiteral value: TimeInterval) { 15 | self.value = value 16 | } 17 | 18 | /// Creates a new instance by decoding from the given decoder. 19 | /// 20 | /// - Parameter decoder: The decoder to read data from. 21 | /// - Throws: An error if reading from the decoder fails, 22 | /// or if the data is corrupted or invalid. 23 | public init(from decoder: Decoder) throws { 24 | let container = try decoder.singleValueContainer() 25 | let stringValue = try container.decode(String.self) 26 | guard let value = Self.timeInterval(stringValue) else { 27 | let context = DecodingError.Context( 28 | codingPath: decoder.codingPath, 29 | debugDescription: "Invalid time value", 30 | underlyingError: nil 31 | ) 32 | throw DecodingError.dataCorrupted(context) 33 | } 34 | self.value = value 35 | } 36 | 37 | /// Creates a new instance from the given description string. 38 | /// 39 | /// - Parameter description: The description string representing the duration. 40 | /// - Returns: A new instance if the description is valid, otherwise ``nil``. 41 | public init?(_ description: String) { 42 | guard let value = Self.timeInterval(description) else { 43 | return nil 44 | } 45 | self.value = value 46 | } 47 | 48 | /// Converts a time string to a ``TimeInterval`` value. 49 | /// 50 | /// - Parameter timeString: The time string to convert. 51 | /// - Returns: The ``TimeInterval`` value representing the time string, 52 | /// or ``nil`` if the string is invalid. 53 | internal static func timeInterval(_ timeString: String) -> TimeInterval? { 54 | let timeStrings = timeString.components(separatedBy: ":").prefix(3) 55 | let doubles = timeStrings.compactMap(Double.init) 56 | guard doubles.count == timeStrings.count else { 57 | return nil 58 | } 59 | return doubles.reduce(0) { partialResult, value in 60 | partialResult * 60.0 + value 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/iTunes/iTunesEpisode.swift: -------------------------------------------------------------------------------- 1 | /// A struct representing an Atom category. 2 | /// A type alias for an iTunes episode. 3 | /// 4 | /// - Note: This type is an alias for ``XMLStringInt``. 5 | /// 6 | /// - SeeAlso: ``XMLStringInt`` 7 | /// - SeeAlso: ``EntryCategory`` 8 | public typealias iTunesEpisode = XMLStringInt 9 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/iTunes/iTunesImage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A type alias for iTunes image links. 4 | public typealias iTunesImage = Link 5 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/Media/iTunes/iTunesOwner.swift: -------------------------------------------------------------------------------- 1 | /// A struct representing an Atom category. 2 | /// A struct representing the owner of an iTunes account. 3 | /// 4 | /// - Note: This struct conforms to the ``Codable`` protocol. 5 | /// 6 | /// - Warning: Do not modify the ``CodingKeys`` enum. 7 | /// 8 | /// - SeeAlso: ``CodingKeys`` 9 | /// 10 | /// - Remark: The ``email`` property is optional. 11 | /// 12 | /// - Important: The ``name`` property is required. 13 | /// 14 | /// - Version: 1.0 15 | /// - SeeAlso: ``EntryCategory`` 16 | public struct iTunesOwner: Codable { 17 | /// The coding keys used to encode and decode the struct. 18 | internal enum CodingKeys: String, CodingKey { 19 | case name = "itunes:name" 20 | case email = "itunes:email" 21 | } 22 | 23 | /// The name of the iTunes owner. 24 | public let name: String 25 | 26 | /// The email address of the iTunes owner. 27 | public let email: String? 28 | } 29 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/OPML/OPML+Body.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension OPML { 4 | public struct Body: Codable, Equatable { 5 | // swiftlint:disable:next nesting 6 | internal enum CodingKeys: String, CodingKey { 7 | case outlines = "outline" 8 | } 9 | 10 | public let outlines: [Outline] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/OPML/OPML+Head.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension OPML { 4 | public struct Head: Codable, Equatable { 5 | public let title: String? 6 | public let dateCreated: String? 7 | public let dateModified: String? 8 | public let ownerName: String? 9 | public let ownerEmail: String? 10 | public let ownerId: String? 11 | public let docs: String? 12 | public let expansionStates: ListString? 13 | public let vertScrollState: Int? 14 | public let windowTop: Int? 15 | public let windowLeft: Int? 16 | public let windowBottom: Int? 17 | public let windowRight: Int? 18 | 19 | // swiftlint:disable:next nesting 20 | internal enum CodingKeys: String, CodingKey { 21 | case title 22 | case dateCreated 23 | case dateModified 24 | case ownerName 25 | case ownerEmail 26 | case ownerId 27 | case docs 28 | case expansionStates = "expansionState" 29 | case vertScrollState 30 | case windowTop 31 | case windowLeft 32 | case windowBottom 33 | case windowRight 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/OPML/OPML+Outline.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // swiftlint:disable nesting discouraged_optional_boolean 4 | 5 | extension OPML { 6 | public struct Outline: Codable, Equatable { 7 | public enum CodingKeys: String, CodingKey { 8 | case text 9 | case title 10 | case description 11 | case type 12 | case url 13 | case htmlUrl 14 | case xmlUrl 15 | case language 16 | case created 17 | case categories = "category" 18 | case isComment 19 | case isBreakpoint 20 | case version 21 | 22 | case outlines = "outline" 23 | } 24 | 25 | public let text: String 26 | public let title: String? 27 | public let description: String? 28 | public let type: OutlineType? 29 | public let url: URL? 30 | public let htmlUrl: URL? 31 | public let xmlUrl: URL? 32 | public let language: String? 33 | public let created: String? 34 | public let categories: ListString? 35 | public let isComment: Bool? 36 | public let isBreakpoint: Bool? 37 | public let version: String? 38 | 39 | public let outlines: [Outline]? 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/OPML/OPML.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct OPML: Codable, Equatable { 4 | internal enum CodingKeys: String, CodingKey { 5 | case version 6 | case head 7 | case body 8 | } 9 | 10 | public let version: String 11 | 12 | public let head: Head 13 | public let body: Body 14 | } 15 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/OPML/OutlineType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum OutlineType: String, Codable { 4 | case rss 5 | case link 6 | case include 7 | } 8 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/SyndicationUpdate/SyndicationUpdate.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // swiftlint:disable line_length 4 | /// Properties concerning how often it is updated a feed is updated. 5 | /// 6 | /// These properties come from 7 | /// [the RDF Site Summary Syndication Module](https://web.resource.org/rss/1.0/modules/syndication/). 8 | public struct SyndicationUpdate: Codable, Equatable { 9 | // swiftlint:enable line_length 10 | 11 | /// Describes the period over which the channel format is updated. 12 | /// The default value is ``SyndicationUpdatePeriod/daily``. 13 | public let period: SyndicationUpdatePeriod 14 | 15 | /// Used to describe the frequency of updates in relation to the update period. 16 | /// The default value is 1. 17 | /// 18 | /// A positive integer indicates how many times in that period the channel is updated. 19 | /// For example, an updatePeriod of daily, and an updateFrequency of 2 20 | /// indicates the channel format is updated twice daily. 21 | public let frequency: Int 22 | 23 | /// Defines a base date 24 | /// to be used in concert with 25 | /// ``SyndicationUpdate/period`` and ``SyndicationUpdate/frequency`` 26 | /// to calculate the publishing schedule. 27 | public let base: Date? 28 | 29 | internal init?( 30 | period: SyndicationUpdatePeriod? = nil, 31 | frequency: Int? = nil, 32 | base: Date? = nil 33 | ) { 34 | guard period != nil || frequency != nil else { 35 | return nil 36 | } 37 | self.period = period ?? .daily 38 | self.frequency = frequency ?? 1 39 | self.base = base 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/SyndicationUpdate/SyndicationUpdateFrequency.swift: -------------------------------------------------------------------------------- 1 | /// Used to describe the frequency of updates 2 | /// in relation to the update period. 3 | /// A positive integer indicates 4 | /// how many times in that period the channel is updated. 5 | public typealias SyndicationUpdateFrequency = XMLStringInt 6 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Formats/SyndicationUpdate/SyndicationUpdatePeriod.swift: -------------------------------------------------------------------------------- 1 | /// Describes the period over which the channel format is updated. 2 | public enum SyndicationUpdatePeriod: String, Codable { 3 | case hourly, daily, weekly, monthly, yearly 4 | 5 | public init(from decoder: Decoder) throws { 6 | let container = try decoder.singleValueContainer() 7 | let stringValue = try container 8 | .decode(String.self) 9 | .trimmingCharacters(in: .whitespacesAndNewlines) 10 | guard let value = Self(rawValue: stringValue) else { 11 | let context = DecodingError.Context( 12 | codingPath: decoder.codingPath, 13 | debugDescription: "Invalid Enum", 14 | underlyingError: nil 15 | ) 16 | throw DecodingError.dataCorrupted(context) 17 | } 18 | self = value 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/SyndiKit/KeyedDecodingContainerProtocol.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension KeyedDecodingContainerProtocol { 4 | internal func decodeDateIfPresentAndValid(forKey key: Key) throws -> Date? { 5 | if let pubDateString = 6 | try decodeIfPresent(String.self, forKey: key), 7 | !pubDateString.isEmpty { 8 | return DateFormatterDecoder.RSS.decoder.decodeString(pubDateString) 9 | } 10 | return nil 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/SyndiKit/Substring.SubSequence.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Substring.SubSequence { 4 | internal func asDouble() -> Double? { 5 | Double(self) 6 | } 7 | 8 | internal func asInt() -> Int? { 9 | guard let double = Double(self) else { 10 | return nil 11 | } 12 | 13 | return Int(double) 14 | } 15 | 16 | internal func asExactInt() -> Int? { 17 | guard let double = Double(self) else { 18 | return nil 19 | } 20 | 21 | return Int(exactly: double) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/SyndiKit/SyndiKit.docc/Resources/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /Sources/SyndiKit/SyndiKit.docc/Resources/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brightdigit/SyndiKit/468f15671688bcede7db40b51cbdb78df949e7e1/Sources/SyndiKit/SyndiKit.docc/Resources/logo.png -------------------------------------------------------------------------------- /Sources/SyndiKit/URL.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension URL { 4 | internal init?(strict string: String) { 5 | guard string.starts(with: "http") else { 6 | return nil 7 | } 8 | 9 | self.init(string: string) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Tests/SyndiKitTests/BlogTests.swift: -------------------------------------------------------------------------------- 1 | @testable import SyndiKit 2 | import XCTest 3 | import XMLCoder 4 | 5 | public final class BlogTests: XCTestCase { 6 | func testBlogs() throws { 7 | let blogs = Content.blogs 8 | let sites = SiteCollectionDirectory(blogs: blogs) 9 | 10 | for languageContent in blogs { 11 | for category in languageContent.categories { 12 | let expectedCount = sites.sites( 13 | withLanguage: languageContent.language, 14 | withCategory: category.slug 15 | ) 16 | .count 17 | XCTAssertEqual( 18 | category.sites.count, 19 | expectedCount, 20 | "mismatch count for \(languageContent.language):\(category.slug)" 21 | ) 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Tests/SyndiKitTests/Content.Directories.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import SyndiKit 3 | 4 | extension Content { 5 | internal enum Directories { 6 | static let data = URL(fileURLWithPath: #file) 7 | .deletingLastPathComponent() 8 | .deletingLastPathComponent() 9 | .deletingLastPathComponent() 10 | .appendingPathComponent("Data") 11 | static let XML = data.appendingPathComponent("XML") 12 | static let JSON = data.appendingPathComponent("JSON") 13 | static let OPML = data.appendingPathComponent("OPML") 14 | static let WordPress = data.appendingPathComponent("WordPress") 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Tests/SyndiKitTests/Content.ResultDictionary.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import SyndiKit 3 | import XMLCoder 4 | 5 | enum Content { 6 | typealias ResultDictionary = [String: Result] 7 | 8 | fileprivate static func resultDictionaryFrom( 9 | directoryURL: URL, 10 | by closure: @escaping (Data) throws -> SuccessValueType 11 | ) throws -> ResultDictionary { 12 | let xmlDataSet = Result { 13 | try FileManager.default.dataFromDirectory(at: directoryURL) 14 | } 15 | 16 | return try xmlDataSet.map { xmlDataSet in 17 | xmlDataSet.flatResultMapValue(closure) 18 | }.map(Dictionary.init(uniqueKeysWithValues:)).get() 19 | } 20 | 21 | static let synDecoder = SynDecoder() 22 | static let xmlDecoder = XMLDecoder() 23 | 24 | static let xmlFeeds = try! Content.resultDictionaryFrom( 25 | directoryURL: Directories.XML, 26 | by: Self.synDecoder.decode(_:) 27 | ) 28 | static let jsonFeeds = try! Content.resultDictionaryFrom( 29 | directoryURL: Directories.JSON, 30 | by: Self.synDecoder.decode(_:) 31 | ) 32 | static let opml = try! Content.resultDictionaryFrom( 33 | directoryURL: Directories.OPML, 34 | by: Self.xmlDecoder.decodeOPML(_:) 35 | ) 36 | static let wordpressDataSet = try! FileManager.default.dataFromDirectory( 37 | at: Directories.WordPress 38 | ) 39 | static let blogs: SiteCollection = try! .init(contentsOf: Directories.data.appendingPathComponent("blogs.json")) 40 | // swiftlint:enable force_try line_length 41 | } 42 | 43 | extension XMLDecoder { 44 | func decodeOPML(_ data: Data) throws -> OPML { 45 | try decode(OPML.self, from: data) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Tests/SyndiKitTests/DecodingErrorTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import SyndiKit 3 | import XCTest 4 | 5 | public final class DecodingErrorTests: XCTestCase { 6 | func testErrorsEmpty() { 7 | let error = DecodingError.failedAttempts([:]) 8 | 9 | guard case let DecodingError.dataCorrupted(context) = error else { 10 | XCTFail() 11 | return 12 | } 13 | 14 | XCTAssertNil(context.underlyingError) 15 | } 16 | 17 | func testErrorsOne() { 18 | let debugDescription = UUID().uuidString 19 | let error = DecodingError.failedAttempts([ 20 | "Test": .dataCorrupted(.init(codingPath: [], debugDescription: debugDescription)) 21 | ]) 22 | 23 | guard case let DecodingError.dataCorrupted(parentContext) = error else { 24 | XCTFail() 25 | return 26 | } 27 | 28 | guard let decodingError = parentContext.underlyingError as? DecodingError else { 29 | XCTFail() 30 | return 31 | } 32 | 33 | guard case let DecodingError.dataCorrupted(childContext) = decodingError else { 34 | XCTFail() 35 | return 36 | } 37 | 38 | XCTAssertEqual(childContext.debugDescription, debugDescription) 39 | } 40 | 41 | func testErrorsMany() { 42 | let errors = [ 43 | "Test1": DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "")), 44 | "Test2": DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "")) 45 | ] 46 | let error = DecodingError.failedAttempts(errors) 47 | 48 | guard case let DecodingError.dataCorrupted(context) = error else { 49 | XCTFail() 50 | return 51 | } 52 | 53 | guard let collection = context.underlyingError as? DecodingError.Dictionary else { 54 | XCTFail() 55 | return 56 | } 57 | 58 | XCTAssertEqual(collection.errors.count, errors.count) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Tests/SyndiKitTests/Extensions/FileManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension FileManager { 4 | internal func dataFromDirectory(at sourceURL: URL) throws -> [(String, Result)] { 5 | let urls = try contentsOfDirectory( 6 | at: sourceURL, 7 | includingPropertiesForKeys: nil, 8 | options: [] 9 | ) 10 | 11 | return urls.mapPairResult { try Data(contentsOf: $0) } 12 | .map { ($0.0.deletingPathExtension().lastPathComponent, $0.1) } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Tests/SyndiKitTests/Extensions/JSONFeed.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SyndiKit 3 | 4 | extension JSONFeed { 5 | internal var homePageURLHttp: URL? { 6 | var components = URLComponents(url: homePageUrl, resolvingAgainstBaseURL: false) 7 | components?.scheme = "http" 8 | return components?.url 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Tests/SyndiKitTests/Extensions/Sequence.swift: -------------------------------------------------------------------------------- 1 | extension Sequence { 2 | internal func mapPairResult( 3 | _ transform: @escaping (Element) throws -> Success 4 | ) -> [(Element, Result)] { 5 | map { element in 6 | (element, Result { try transform(element) }) 7 | } 8 | } 9 | 10 | internal func mapResult( 11 | _ transform: @escaping (Element) throws -> Success 12 | ) -> [Result] { 13 | map { element in 14 | Result { try transform(element) } 15 | } 16 | } 17 | 18 | internal func flatResultMapValue( 19 | _ transform: @escaping (SuccessValue) throws -> NewSuccess 20 | ) -> [(SuccessKey, Result)] 21 | where Element == (SuccessKey, Result) { 22 | map { 23 | let value = $0.1.flatMap { value in 24 | Result { try transform(value) } 25 | } 26 | return ($0.0, value) 27 | } 28 | } 29 | 30 | internal func flatResultMap( 31 | _ transform: @escaping (Success) throws -> NewSuccess 32 | ) -> [Result] 33 | where Element == Result { 34 | map { 35 | $0.flatMap { success in 36 | Result { 37 | try transform(success) 38 | } 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests/SyndiKitTests/Extensions/SiteCollection.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import SyndiKit 3 | 4 | extension SiteCollection { 5 | internal init(contentsOf url: URL, using decoder: JSONDecoder = .init()) throws { 6 | let data = try Data(contentsOf: url) 7 | self = try decoder.decode(SiteCollection.self, from: data) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Tests/SyndiKitTests/Extensions/String.swift: -------------------------------------------------------------------------------- 1 | extension String { 2 | internal func trimAndNilIfEmpty() -> String? { 3 | let text = trimmingCharacters(in: .whitespacesAndNewlines) 4 | return text.isEmpty ? nil : text 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Tests/SyndiKitTests/Extensions/URL.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension URL { 4 | var remainingPath: String { 5 | let path = self.path 6 | 7 | return path == "/" ? "" : path 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Tests/SyndiKitTests/OPMLTests.swift: -------------------------------------------------------------------------------- 1 | @testable import SyndiKit 2 | import XCTest 3 | 4 | internal final class OPMLTests: XCTestCase { 5 | internal func testSubscriptionList() throws { 6 | let opml = try Content.opml["subscriptionList"]?.get() 7 | 8 | XCTAssertEqual(opml?.head.title, "mySubscriptions.opml") 9 | XCTAssertEqual(opml?.head.ownerEmail, "dave@scripting.com") 10 | XCTAssertEqual(opml?.body.outlines.count, 13) 11 | 12 | let firstOutline = opml?.body.outlines.first 13 | 14 | XCTAssertEqual(firstOutline?.text, "CNET News.com") 15 | 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.") 16 | XCTAssertEqual(firstOutline?.htmlUrl, URL(string: "http://news.com.com/")!) 17 | XCTAssertEqual(firstOutline?.language, "unknown") 18 | XCTAssertEqual(firstOutline?.title, "CNET News.com") 19 | XCTAssertEqual(firstOutline?.type, .rss) 20 | XCTAssertEqual(firstOutline?.version, "RSS2") 21 | XCTAssertEqual(firstOutline?.xmlUrl, URL(string: "http://news.com.com/2547-1_3-0-5.xml")!) 22 | } 23 | 24 | internal func testStates() throws { 25 | let opml = try Content.opml["states"]?.get() 26 | 27 | XCTAssertEqual(opml?.head.title, "states.opml") 28 | XCTAssertEqual(opml?.head.ownerEmail, "dave@scripting.com") 29 | XCTAssertEqual(opml?.body.outlines.count, 1) 30 | 31 | let usOutline = opml?.body.outlines.first 32 | 33 | XCTAssertEqual(usOutline?.text, "United States") 34 | 35 | XCTAssertEqual(usOutline?.outlines?.count, 8) 36 | 37 | let farWestOutline = usOutline?.outlines?.first 38 | 39 | XCTAssertEqual(farWestOutline?.text, "Far West") 40 | XCTAssertEqual(farWestOutline?.outlines?.count, 6) 41 | 42 | let nevadaOutline = farWestOutline?.outlines?[3] 43 | XCTAssertEqual(nevadaOutline?.outlines?.count, 4) 44 | } 45 | 46 | internal func testCategory() throws { 47 | let opml = try Content.opml["category"]?.get() 48 | 49 | XCTAssertEqual(opml?.head.title, "Illustrating the category attribute") 50 | XCTAssertEqual(opml?.body.outlines.count, 1) 51 | 52 | let outline = opml?.body.outlines.first 53 | 54 | XCTAssertEqual(outline?.text, "The Mets are the best team in baseball.") 55 | XCTAssertEqual(outline?.categories?.values.count, 2) 56 | XCTAssertEqual(outline?.categories?.values[0], "/Philosophy/Baseball/Mets") 57 | XCTAssertEqual(outline?.categories?.values[1], "/Tourism/New York") 58 | } 59 | 60 | internal func testPlacesLived() throws { 61 | let opml = try Content.opml["placesLived"]?.get() 62 | 63 | XCTAssertEqual(opml?.head.title, "placesLived.opml") 64 | XCTAssertEqual(opml?.head.ownerId, "http://www.opml.org/profiles/sendMail?usernum=1") 65 | XCTAssertEqual(opml?.head.expansionStates?.values.count, 6) 66 | XCTAssertEqual(opml?.head.expansionStates?.values[0], 1) 67 | XCTAssertEqual(opml?.head.expansionStates?.values[3], 10) 68 | } 69 | 70 | internal func testSimpleScript() throws { 71 | let opml = try Content.opml["simpleScript"]?.get() 72 | 73 | XCTAssertEqual(opml?.head.title, "workspace.userlandsamples.doSomeUpstreaming") 74 | XCTAssertEqual(opml?.head.expansionStates?.values.count, 3) 75 | XCTAssertEqual(opml?.head.expansionStates?.values[0], 1) 76 | XCTAssertEqual(opml?.head.expansionStates?.values[2], 4) 77 | 78 | XCTAssertEqual(opml?.body.outlines.count, 4) 79 | 80 | let isCommentOutline = opml?.body.outlines.first 81 | XCTAssertEqual(isCommentOutline?.text, "Changes") 82 | XCTAssertEqual(isCommentOutline?.isComment, true) 83 | 84 | let isBreakpointOutline = opml?.body.outlines[1].outlines?.first 85 | XCTAssertEqual(isBreakpointOutline?.text, "file.surefilepath (f)") 86 | XCTAssertEqual(isBreakpointOutline?.isBreakpoint, true) 87 | } 88 | 89 | internal func testInvalidExpansionStateType() throws { 90 | XCTAssertThrowsError(try Content.opml["category_invalidExpansionState"]?.get()) { error in 91 | guard case let .typeMismatch(type, context) = error as? DecodingError else { 92 | XCTFail("Expected typeMismatch error.") 93 | return 94 | } 95 | 96 | XCTAssertTrue(type is Int.Type) 97 | XCTAssertEqual( 98 | context.debugDescription, 99 | "Invalid value: one" 100 | ) 101 | } 102 | } 103 | 104 | internal func testType() throws { 105 | var opml = try Content.opml["subscriptionList"]?.get() 106 | 107 | XCTAssertEqual(opml?.body.outlines.first?.type, .rss) 108 | XCTAssertNotNil(opml?.body.outlines.first?.text) 109 | XCTAssertNotNil(opml?.body.outlines.first?.xmlUrl) 110 | 111 | opml = try Content.opml["directory"]?.get() 112 | 113 | XCTAssertEqual(opml?.body.outlines.first?.type, .link) 114 | XCTAssertNotNil(opml?.body.outlines.first?.url) 115 | 116 | opml = try Content.opml["placesLived"]?.get() 117 | let floridaOutline = opml?.body.outlines.first? 118 | .outlines?.first( 119 | where: { $0.text == "Florida" } 120 | ) 121 | 122 | XCTAssertEqual(floridaOutline?.type, .include) 123 | XCTAssertNotNil(floridaOutline?.url) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Tests/SyndiKitTests/RSSCoded.Durations.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension SyndiKitTests { 4 | internal static let durationSets: [String: [TimeInterval]] = [ 5 | "empowerapps-show": [ 6 | 2_746, 7 | 3_500, 8 | 5_145, 9 | 2_589, 10 | 1_796, 11 | 2_401, 12 | 2_052, 13 | 2_323, 14 | 2_631, 15 | 1_971, 16 | 1_877, 17 | 2_080, 18 | 2_240, 19 | 2_656, 20 | 1_843, 21 | 2_237, 22 | 3_421, 23 | 2_788, 24 | 3_041, 25 | 2_138, 26 | 1_460, 27 | 2_768, 28 | 2_372, 29 | 2_309, 30 | 1_804, 31 | 1_916, 32 | 3_022, 33 | 2_541, 34 | 2_729, 35 | 3_386, 36 | 2_281, 37 | 2_962, 38 | 3_307, 39 | 2_648, 40 | 2_667, 41 | 2_783, 42 | 2_693, 43 | 2_290, 44 | 1_741, 45 | 3_341, 46 | 1_607, 47 | 889, 48 | 2_535, 49 | 1_762, 50 | 2_799, 51 | 2_956, 52 | 2_976, 53 | 5_011, 54 | 2_140, 55 | 2_535, 56 | 2_497, 57 | 3_405, 58 | 1_210, 59 | 2_405, 60 | 2_991, 61 | 3_540, 62 | 1_868, 63 | 2_248, 64 | 4_199, 65 | 1_289, 66 | 3_155, 67 | 2_787, 68 | 2_222, 69 | 2_555, 70 | 479, 71 | 2_607, 72 | 1_985, 73 | 2_565, 74 | 2_761, 75 | 2_026, 76 | 2_452, 77 | 3_163, 78 | 1_127, 79 | 3_195, 80 | 3_890, 81 | 1_358, 82 | 2_489, 83 | 2_465, 84 | 2_083, 85 | 2_824, 86 | 2_137, 87 | 2_452, 88 | 2_242, 89 | 1_622, 90 | 1_081, 91 | 1_979, 92 | 2_080, 93 | 1_225, 94 | 2_204, 95 | 1_703, 96 | 2_495, 97 | 922, 98 | 1_433, 99 | 1_776 100 | ], 101 | "raywenderlich": [ 102 | 96.0, 103 | 2_893.0, 104 | 2_735.0, 105 | 2_653.0, 106 | 2_497.0, 107 | 2_592.0, 108 | 2_542.0, 109 | 2_577.0, 110 | 2_455.0, 111 | 1_935.0, 112 | 2_689.0, 113 | 2_819.0, 114 | 2_871.0, 115 | 653.0, 116 | 2_454.0, 117 | 2_696.0, 118 | 2_849.0, 119 | 2_778.0, 120 | 2_742.0, 121 | 2_705.0, 122 | 2_750.0, 123 | 2_734.0, 124 | 2_684.0, 125 | 2_676.0, 126 | 2_698.0, 127 | 2_696.0, 128 | 2_698.0, 129 | 600.0, 130 | 2_549.0, 131 | 2_398.0, 132 | 2_344.0, 133 | 2_401.0, 134 | 2_419.0, 135 | 2_391.0, 136 | 3_576.0, 137 | 2_402.0, 138 | 2_422.0, 139 | 2_385.0, 140 | 2_374.0, 141 | 2_378.0, 142 | 2_494.0, 143 | 698.0, 144 | 2_798.0, 145 | 2_456.0, 146 | 2_402.0, 147 | 2_417.0, 148 | 2_407.0, 149 | 3_597.0, 150 | 2_481.0, 151 | 2_481.0, 152 | 3_725.0, 153 | 2_395.0, 154 | 2_380.0, 155 | 2_398.0, 156 | 316.0, 157 | 1_949.0, 158 | 2_572.0, 159 | 2_472.0, 160 | 2_412.0, 161 | 2_415.0, 162 | 2_378.0, 163 | 2_361.0, 164 | 2_423.0, 165 | 2_389.0, 166 | 917.0, 167 | 2_578.0, 168 | 2_622.0, 169 | 2_585.0, 170 | 2_462.0, 171 | 1_087.0, 172 | 2_697.0, 173 | 2_584.0, 174 | 3_005.0, 175 | 2_431.0, 176 | 2_547.0, 177 | 2_547.0, 178 | 2_510.0, 179 | 2_471.0, 180 | 2_476.0, 181 | 2_424.0, 182 | 2_516.0, 183 | 2_408.0, 184 | 2_281.0, 185 | 2_502.0, 186 | 2_483.0, 187 | 2_521.0, 188 | 2_436.0, 189 | 2_456.0, 190 | 2_364.0, 191 | 2_550.0, 192 | 2_411.0, 193 | 2_419.0, 194 | 2_556.0, 195 | 2_282.0, 196 | 2_312.0, 197 | 2_243.0, 198 | 2_376.0, 199 | 2_373.0, 200 | 2_153.0, 201 | 2_305.0 202 | ] 203 | ] 204 | } 205 | -------------------------------------------------------------------------------- /Tests/SyndiKitTests/RSSGUIDTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SyndiKit 3 | import XCTest 4 | 5 | final class RSSGUIDTests: XCTestCase { 6 | func testGUIDURL() { 7 | let urlString = "https://developer.apple.com/news/?id=jxky8h89" 8 | 9 | let urlGUID = EntryID(string: urlString) 10 | 11 | guard case let .url(url) = urlGUID else { 12 | XCTFail() 13 | return 14 | } 15 | XCTAssertEqual(url, URL(string: urlString)) 16 | } 17 | 18 | func testGUIDUUID() { 19 | let expectedUUID = UUID() 20 | 21 | let expectedUUIDString = expectedUUID.uuidString 22 | let uuidGUID = EntryID(string: expectedUUIDString) 23 | 24 | guard case let .uuid(actualUUID) = uuidGUID else { 25 | XCTFail() 26 | return 27 | } 28 | XCTAssertEqual(actualUUID, expectedUUID) 29 | } 30 | 31 | func testGUIDYouTube() { 32 | let expectedPath = ["yt", "video", "3hccNoPE59U"] 33 | 34 | let pathGUID = EntryID(string: expectedPath.joined(separator: ":")) 35 | 36 | guard case let .path(actualPath, ":") = pathGUID else { 37 | XCTFail() 38 | return 39 | } 40 | XCTAssertEqual(actualPath, expectedPath) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests/SyndiKitTests/RSSItemCategoryTests.swift: -------------------------------------------------------------------------------- 1 | @testable import SyndiKit 2 | import XCTest 3 | 4 | final class RSSItemCategoryTests: XCTestCase { 5 | func testTwoEqualCategories() { 6 | let c1 = RSSItemCategory( 7 | value: "Top Menu", 8 | domain: "nav_menu", 9 | nicename: "top-menu" 10 | ) 11 | 12 | let c2 = RSSItemCategory( 13 | value: "Top Menu", 14 | domain: "nav_menu", 15 | nicename: "top-menu" 16 | ) 17 | 18 | XCTAssertEqual(c1, c2) 19 | } 20 | 21 | func testTwoUnequalCategories() { 22 | let c1 = RSSItemCategory( 23 | value: "Uncategorized", 24 | domain: "category", 25 | nicename: "uncategorized" 26 | ) 27 | 28 | let c2 = RSSItemCategory( 29 | value: "Top Menu", 30 | domain: "nav_menu", 31 | nicename: "top-menu" 32 | ) 33 | 34 | XCTAssertNotEqual(c1, c2) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Tests/SyndiKitTests/UTF8EncodedURLTests.swift: -------------------------------------------------------------------------------- 1 | @testable import SyndiKit 2 | import XCTest 3 | import XMLCoder 4 | 5 | internal final class UTF8EncodedURLTests: XCTestCase { 6 | internal func testDecode() throws { 7 | let expectedURL = URL(strict: "http://www.example.com/index.php")! 8 | let urlStr = """ 9 | "\(expectedURL)" 10 | """ 11 | 12 | guard let data = urlStr.data(using: .utf8) else { 13 | XCTFail("Expected data out of \(urlStr)") 14 | return 15 | } 16 | 17 | let sut = try JSONDecoder().decode(UTF8EncodedURL.self, from: data) 18 | 19 | XCTAssertEqual(sut.value, expectedURL) 20 | XCTAssertNil(sut.string) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/SyndiKitTests/WordPressElementsTests.swift: -------------------------------------------------------------------------------- 1 | @testable import SyndiKit 2 | import XCTest 3 | 4 | final class WordPressElementsTests: XCTestCase { 5 | func testCategoryEquatable() { 6 | let c1 = WordPressElements.Category( 7 | termID: 1, 8 | niceName: .init(stringLiteral: "uncategorized"), 9 | parent: .init(stringLiteral: ""), 10 | name: "Uncategorized" 11 | ) 12 | 13 | let c2 = WordPressElements.Category( 14 | termID: 2, 15 | niceName: .init(stringLiteral: "podcasting"), 16 | parent: .init(stringLiteral: ""), 17 | name: "Podcasting" 18 | ) 19 | 20 | XCTAssertNotEqual(c1, c2) 21 | } 22 | 23 | func testTagEquatable() { 24 | let t1 = WordPressElements.Tag( 25 | termID: 1, 26 | slug: .init(stringLiteral: "uncategorized"), 27 | name: .init(stringLiteral: "uncategorized") 28 | ) 29 | 30 | let t2 = WordPressElements.Tag( 31 | termID: 2, 32 | slug: .init(stringLiteral: "podcasting"), 33 | name: .init(stringLiteral: "Podcasting") 34 | ) 35 | 36 | XCTAssertNotEqual(t1, t2) 37 | } 38 | 39 | func testPostMetaEquatable() { 40 | let pm1 = WordPressElements.PostMeta( 41 | key: "_edit_last", 42 | value: "1" 43 | ) 44 | 45 | let pm2 = WordPressElements.PostMeta( 46 | key: "_thumbnail_id", 47 | value: "57" 48 | ) 49 | 50 | XCTAssertNotEqual(pm1, pm2) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Tests/SyndiKitTests/XMLStringIntTests.swift: -------------------------------------------------------------------------------- 1 | @testable import SyndiKit 2 | import XCTest 3 | import XMLCoder 4 | 5 | internal final class XMLStringIntTests: XCTestCase { 6 | internal func testDecodeValidXMLValue() throws { 7 | let expectedAge = 10 8 | let xmlStr = """ 9 | \(expectedAge) 10 | """ 11 | 12 | guard let data = xmlStr.data(using: .utf8) else { 13 | XCTFail("Expected data out of \(xmlStr)") 14 | return 15 | } 16 | 17 | let sut = try XMLDecoder().decode(XMLStringInt.self, from: data) 18 | 19 | XCTAssertEqual(sut.value, expectedAge) 20 | } 21 | 22 | internal func testDecodeInvalidXMLValue() throws { 23 | let xmlStr = """ 24 | invalid 25 | """ 26 | 27 | guard let data = xmlStr.data(using: .utf8) else { 28 | XCTFail("Expected data out of \(xmlStr)") 29 | return 30 | } 31 | 32 | XCTAssertThrowsError(try XMLDecoder().decode(XMLStringInt.self, from: data)) { error in 33 | XCTAssertNotNil(error as? DecodingError) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "Tests/**/*" 3 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "DerivedData/Build/Products/Debug/SyndiKit.doccarchive/" 3 | 4 | [[redirects]] 5 | from = "/documentation/*" 6 | status = 200 7 | to = "/index.html" 8 | 9 | [[redirects]] 10 | from = "/tutorials/*" 11 | status = 200 12 | to = "/index.html" 13 | 14 | [[redirects]] 15 | from = "/data/documentation.json" 16 | status = 200 17 | to = "/data/documentation/syndikit.json" 18 | 19 | [[redirects]] 20 | force = true 21 | from = "/" 22 | status = 302 23 | to = "/documentation/" 24 | 25 | [[redirects]] 26 | force = true 27 | from = "/documentation" 28 | status = 302 29 | to = "/documentation/" 30 | 31 | [[redirects]] 32 | force = true 33 | from = "/tutorials" 34 | status = 302 35 | to = "/tutorials/" -------------------------------------------------------------------------------- /project.yml: -------------------------------------------------------------------------------- 1 | name: SyndiKit 2 | settings: 3 | LINT_MODE: ${LINT_MODE} 4 | packages: 5 | SyndiKit: 6 | path: . 7 | aggregateTargets: 8 | Lint: 9 | buildScripts: 10 | - path: Scripts/lint.sh 11 | name: Lint 12 | basedOnDependencyAnalysis: false 13 | schemes: {} --------------------------------------------------------------------------------