├── .github ├── README-template.md ├── dependabot.yml ├── labeler.yml ├── release-drafter.yml ├── version-drafter.yml └── workflows │ ├── build.yml │ ├── codeql-analysis.yml │ ├── pr-agent.yml │ ├── pr-check.yml │ ├── publish-release.yml │ └── release-drafter.yml ├── .gitignore ├── LICENSE ├── README.md ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── src ├── main └── java │ ├── com │ └── apptasticsoftware │ │ └── rssreader │ │ ├── AbstractRssReader.java │ │ ├── Channel.java │ │ ├── DateTime.java │ │ ├── DateTimeParser.java │ │ ├── Enclosure.java │ │ ├── Image.java │ │ ├── Item.java │ │ ├── RssReader.java │ │ ├── internal │ │ ├── DaemonThreadFactory.java │ │ ├── StreamUtil.java │ │ ├── XMLInputFactorySecurity.java │ │ └── stream │ │ │ ├── AbstractAutoCloseStream.java │ │ │ ├── AutoCloseDoubleStream.java │ │ │ ├── AutoCloseIntStream.java │ │ │ ├── AutoCloseLongStream.java │ │ │ └── AutoCloseStream.java │ │ ├── module │ │ ├── itunes │ │ │ ├── ItunesChannel.java │ │ │ ├── ItunesItem.java │ │ │ ├── ItunesOwner.java │ │ │ └── ItunesRssReader.java │ │ └── mediarss │ │ │ ├── MediaRssItem.java │ │ │ ├── MediaRssReader.java │ │ │ └── MediaThumbnail.java │ │ ├── package-info.java │ │ └── util │ │ ├── Default.java │ │ ├── ItemComparator.java │ │ ├── Mapper.java │ │ └── Util.java │ └── module-info.java └── test ├── java └── com │ └── apptasticsoftware │ ├── integrationtest │ ├── CloseTest.java │ ├── ConnectionTest.java │ ├── RssReaderIntegrationTest.java │ └── SortTest.java │ └── rssreader │ ├── DateTimeTest.java │ ├── RssReaderTest.java │ ├── internal │ └── RssServer.java │ ├── module │ ├── itunes │ │ └── ItunesRssReaderTest.java │ └── mediarss │ │ └── MediaRssReaderTest.java │ └── util │ ├── ItemComparatorTest.java │ ├── MapperTest.java │ └── UtilTest.java └── resources ├── atom-feed-category.xml ├── atom-feed.xml ├── bad-image-width-height.xml ├── empty-category.xml ├── item-sort-test.xml ├── itunes-podcast.xml ├── media-rss.xml ├── mockito-extensions └── org.mockito.plugins.MockMaker ├── multiple-categories.xml ├── multiple-enclosures.xml ├── multiple-title-on-different-levels.xml ├── podcast-with-bad-enclosure.xml ├── rdf-feed.xml ├── rss-feed.xml └── starts-with-whitespace-and-missing-namespace.xml /.github/README-template.md: -------------------------------------------------------------------------------- 1 | RSS Reader 2 | ========== 3 | 4 | [![Build](https://github.com/w3stling/rssreader/actions/workflows/build.yml/badge.svg)](https://github.com/w3stling/rssreader/actions/workflows/build.yml) 5 | [![Download](https://img.shields.io/badge/download-%%version%%-brightgreen.svg)](https://central.sonatype.com/artifact/com.apptasticsoftware/rssreader/%%version%%/overview) 6 | [![Javadoc](https://img.shields.io/badge/javadoc-%%version%%-blue.svg)](https://w3stling.github.io/rssreader/javadoc/%%version%%) 7 | [![License](http://img.shields.io/:license-MIT-blue.svg?style=flat-round)](http://apptastic-software.mit-license.org) 8 | [![CodeQL](https://github.com/w3stling/rssreader/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/w3stling/rssreader/actions/workflows/codeql-analysis.yml) 9 | [![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=w3stling_rssreader&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=w3stling_rssreader) 10 | [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=w3stling_rssreader&metric=coverage)](https://sonarcloud.io/summary/new_code?id=w3stling_rssreader) 11 | [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=w3stling_rssreader&metric=bugs)](https://sonarcloud.io/summary/new_code?id=w3stling_rssreader) 12 | [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=w3stling_rssreader&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=w3stling_rssreader) 13 | [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=w3stling_rssreader&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=w3stling_rssreader) 14 | 15 | > [!NOTE] 16 | > From version 3.0.0: 17 | > * New Java package name 18 | > * New group ID in Maven / Gradle dependency declaration 19 | > * Moved repository from `JCenter` to `Maven Central Repository` 20 | 21 | RSS Reader is a simple Java library for reading RSS and Atom feeds. It has zero 3rd party dependencies, a low memory footprint and can process large feeds. Requires at minimum Java 11. 22 | 23 | RSS (Rich Site Summary) is a type of web feed which allows users to access updates to online content in a 24 | standardized, computer-readable format. It removes the need for the user to manually 25 | check the website for new content. 26 | 27 | 28 | Examples 29 | -------- 30 | ### Read RSS feed 31 | Reads from a RSS (or Atom) feed. 32 | ```java 33 | RssReader rssReader = new RssReader(); 34 | List items = rssReader.read(URL) 35 | .toList(); 36 | ``` 37 | 38 | Extract all items that contains the word football in the title. 39 | ```java 40 | RssReader reader = new RssReader(); 41 | Stream rssFeed = reader.read(URL); 42 | List footballArticles = rssFeed.filter(i -> i.getTitle().equals(Optional.of("football"))) 43 | .toList(); 44 | ``` 45 | 46 | ### Read feed from a file 47 | Reading from file using InputStream 48 | ```java 49 | InputStream inputStream = new FileInputStream("/path/to/file"); 50 | List items = new RssReader().read(inputStream); 51 | .toList(); 52 | ``` 53 | 54 | Reading from file using file URI 55 | ```java 56 | List items = new RssReader().read("file:/path/to/file"); 57 | .toList(); 58 | ``` 59 | 60 | 61 | ### Read from multiple feeds 62 | Read from multiple feeds into a single stream of items sorted in descending (newest first) publication date order and prints the title. 63 | ```java 64 | List urls = List.of(URL1, URL2, URL3, URL4, URL5); 65 | new RssReader().read(urls) 66 | .sorted() 67 | .map(Item::getTitle) 68 | .forEach(System.out::println); 69 | ``` 70 | 71 | To change sort order to ascending (oldest first) publication date 72 | ```java 73 | .sorted(ItemComparator.oldestPublishedItemFirst()) 74 | ``` 75 | For sorting on updated date instead of publication date 76 | ```java 77 | .sorted(ItemComparator.newestUpdatedItemFirst()) 78 | .sorted(ItemComparator.oldestUpdatedItemFirst()) 79 | ``` 80 | 81 | 82 | ### Podcast / iTunes module 83 | Use iTunes module for extracting data from [Podcast][4] specific tags and attributes. 84 | ```java 85 | List items = new ItunesRssReader().read(URL) 86 | .toList(); 87 | ``` 88 | 89 | ### Custom RSS / Atom feed extensions 90 | Support for mapping custom tags and attributes in feed to item and channel object. 91 | ```java 92 | List items = new RssReader() 93 | .addItemExtension("dc:creator", Item::setAuthor) 94 | .addItemExtension("dc:date", Item::setPubDate) 95 | .read("https://lwn.net/headlines/rss") 96 | .toList(); 97 | ``` 98 | 99 | Download 100 | -------- 101 | 102 | Download [the latest JAR][1] or grab via [Maven][2] or [Gradle][3]. 103 | 104 | ### Maven setup 105 | Add dependency declaration: 106 | ```xml 107 | 108 | ... 109 | 110 | 111 | com.apptasticsoftware 112 | rssreader 113 | %%version%% 114 | 115 | 116 | ... 117 | 118 | ``` 119 | 120 | ### Gradle setup 121 | Add dependency declaration: 122 | ```groovy 123 | dependencies { 124 | implementation 'com.apptasticsoftware:rssreader:%%version%%' 125 | } 126 | ``` 127 | 128 | 129 | Markup Validation Services 130 | ------- 131 | Useful links for validating feeds 132 | 133 | ### RSS / Atom 134 | https://validator.w3.org/feed/
135 | https://www.feedvalidator.org/check.cgi 136 | 137 | ### Podcast / iTunes 138 | https://podba.se/validate/
139 | https://www.castfeedvalidator.com/ 140 | 141 | 142 | 143 | License 144 | ------- 145 | 146 | MIT License 147 | 148 | Copyright (c) %%year%%, Apptastic Software 149 | 150 | Permission is hereby granted, free of charge, to any person obtaining a copy 151 | of this software and associated documentation files (the "Software"), to deal 152 | in the Software without restriction, including without limitation the rights 153 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 154 | copies of the Software, and to permit persons to whom the Software is 155 | furnished to do so, subject to the following conditions: 156 | 157 | The above copyright notice and this permission notice shall be included in all 158 | copies or substantial portions of the Software. 159 | 160 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 161 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 162 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 163 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 164 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 165 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 166 | SOFTWARE. 167 | 168 | 169 | [1]: https://central.sonatype.com/artifact/com.apptasticsoftware/rssreader/%%version%%/overview 170 | [2]: https://maven.apache.org 171 | [3]: https://gradle.org 172 | [4]: https://help.apple.com/itc/podcasts_connect/#/itcb54353390 -------------------------------------------------------------------------------- /.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://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gradle" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | # Add a label to any pull request whose head branch matches the specified pattern. 2 | 'enhancement': 3 | - head-branch: ['^feature/'] 4 | 'bug': 5 | - head-branch: ['^bug/'] 6 | 'chore': 7 | - head-branch: ['^chore/'] 8 | 'documentation': 9 | - head-branch: ['^doc/'] 10 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | categories: 4 | - title: '🚨 Breaking Changes' 5 | label: 'breaking change' 6 | - title: '🚀 Features' 7 | labels: 8 | - 'feature' 9 | - 'enhancement' 10 | - title: '🔒 Security' 11 | label: 'security' 12 | - title: '🛠 Improvements' 13 | label: 'improvement' 14 | - title: '⚡️ Performance' 15 | label: 'performance' 16 | - title: '🐛 Bug Fixes' 17 | label: 'bug' 18 | - title: '📚 Documentation' 19 | label: 'documentation' 20 | - title: '🧰 Maintenance' 21 | label: 'chore' 22 | - title: '♻️ Refactoring' 23 | label: 'refactor' 24 | - title: '🧪 Tests' 25 | label: 'test' 26 | - title: '🎨️ Style' 27 | labels: 28 | - 'style' 29 | - 'cleanup' 30 | - title: '📦 Dependencies' 31 | label: 'dependencies' 32 | - title: '🚧 Wip' 33 | label: 'wip' 34 | change-template: '- $TITLE @$AUTHOR (#$NUMBER)' 35 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 36 | version-resolver: 37 | major: 38 | labels: 39 | - 'breaking change' 40 | minor: 41 | labels: 42 | - 'feature' 43 | - 'enhancement' 44 | - 'improvement' 45 | default: patch 46 | exclude-contributors: 47 | - 'w3stling' 48 | - 'dependabot' 49 | template: | 50 | ## What's Changed 51 | $CHANGES -------------------------------------------------------------------------------- /.github/version-drafter.yml: -------------------------------------------------------------------------------- 1 | major-labels: ['semver:major', 'breaking change'] 2 | minor-labels: ['semver:minor','feature','enhancement','improvement'] 3 | patch-labels: ['semver:patch','bug'] -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Gradle 2 | 3 | name: 🏗️ Build 4 | 5 | on: 6 | schedule: 7 | - cron: '40 5 * * SAT' 8 | push: 9 | branches: [ master ] 10 | tags-ignore: 11 | - 'v*' 12 | 13 | jobs: 14 | build: 15 | name: 🏗️ Build and Test 16 | runs-on: ubuntu-latest 17 | outputs: 18 | next-version: ${{ steps.version.outputs.next-version }} 19 | steps: 20 | - name: Build trigger event 🔎 21 | run: | 22 | echo "event name - ${{github.event_name}}" 23 | echo "github.ref - ${{github.ref}}" 24 | 25 | - name: Checkout repository ⚙️ 26 | uses: actions/checkout@v4 27 | with: 28 | # Disabling shallow clone is recommended for improving relevancy of reporting 29 | fetch-depth: 0 30 | 31 | - name: Setup Java 21 ⚙️ 32 | uses: actions/setup-java@v4 33 | with: 34 | java-version: '21' 35 | distribution: 'temurin' 36 | 37 | - name: Setup Gradle dependencies cache ⚙️ 38 | uses: actions/cache@v4 39 | with: 40 | path: ~/.gradle/caches 41 | key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle', '**/*.gradle.kts') }} 42 | 43 | - name: Setup Gradle wrapper cache ⚙️ 44 | uses: actions/cache@v4 45 | with: 46 | path: ~/.gradle/wrapper 47 | key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} 48 | 49 | - name: Validate Gradle wrapper ⚙️ 50 | uses: gradle/actions/wrapper-validation@v4 51 | 52 | - name: Grant execute permission for gradlew ⚙️ 53 | run: chmod +x gradlew 54 | 55 | - name: Build and test 🏗️ 56 | run: ./gradlew clean build jacocoTestReport javadoc 57 | 58 | - name: Get version number 🔢 59 | id: get-version 60 | uses: release-drafter/release-drafter@v6 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | 64 | - name: Store next version 🔢 65 | id: version 66 | run: | 67 | echo "next-version=${{ steps.get-version.outputs.tag_name }}" | cut -c -13,15- >> $GITHUB_OUTPUT 68 | 69 | - name: Print next version 🔢 70 | run: | 71 | echo "Next version: ${{ steps.version.outputs.next-version }}" 72 | 73 | code-analysis: 74 | name: 🔎 Code Analysis 75 | needs: [build] 76 | if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/master' && github.event_name != 'schedule' && github.repository == 'w3stling/rssreader' }} 77 | runs-on: ubuntu-latest 78 | 79 | steps: 80 | - name: Checkout repository ⚙️ 81 | uses: actions/checkout@v4 82 | with: 83 | # Disabling shallow clone is recommended for improving relevancy of reporting 84 | fetch-depth: 0 85 | 86 | - name: Setup Java 21 ⚙️ 87 | uses: actions/setup-java@v4 88 | with: 89 | java-version: '21' 90 | distribution: 'temurin' 91 | 92 | - name: Cache SonarCloud packages ⚙️ 93 | uses: actions/cache@v4 94 | with: 95 | path: ~/.sonar/cache 96 | key: ${{ runner.os }}-sonar 97 | 98 | - name: Setup Gradle dependencies cache ⚙️ 99 | uses: actions/cache@v4 100 | with: 101 | path: ~/.gradle/caches 102 | key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle', '**/*.gradle.kts') }} 103 | 104 | - name: Setup Gradle wrapper cache ⚙️ 105 | uses: actions/cache@v4 106 | with: 107 | path: ~/.gradle/wrapper 108 | key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} 109 | 110 | - name: Validate Gradle wrapper ⚙️ 111 | uses: gradle/actions/wrapper-validation@v4 112 | 113 | - name: Grant execute permission for gradlew ⚙️ 114 | run: chmod +x gradlew 115 | 116 | - name: Next version 🔢 117 | run: | 118 | echo "Next version: ${{ needs.build.outputs.next-version }}" 119 | 120 | - name: Analyze code 🔎️ 121 | run: ./gradlew test jacocoTestReport sonar --info -Dsonar.projectVersion=${{ needs.build.outputs.next-version }}-SNAPSHOT 122 | env: 123 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 124 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 125 | 126 | 127 | documentation: 128 | name: 📚 Publish Javadoc 129 | needs: [build] 130 | if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/master' && github.event_name != 'schedule' }} 131 | runs-on: ubuntu-latest 132 | 133 | steps: 134 | - name: Checkout repository ⚙️ 135 | uses: actions/checkout@v4 136 | with: 137 | # Disabling shallow clone is recommended for improving relevancy of reporting 138 | fetch-depth: 0 139 | 140 | - name: Setup Java 21 ⚙️ 141 | uses: actions/setup-java@v4 142 | with: 143 | java-version: '21' 144 | distribution: 'temurin' 145 | 146 | - name: Setup Gradle dependencies cache ⚙️ 147 | uses: actions/cache@v4 148 | with: 149 | path: ~/.gradle/caches 150 | key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle', '**/*.gradle.kts') }} 151 | 152 | - name: Setup Gradle wrapper cache ⚙️ 153 | uses: actions/cache@v4 154 | with: 155 | path: ~/.gradle/wrapper 156 | key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} 157 | 158 | - name: Validate Gradle wrapper ⚙️ 159 | uses: gradle/actions/wrapper-validation@v4 160 | 161 | - name: Grant execute permission for gradlew ⚙️ 162 | run: chmod +x gradlew 163 | 164 | - name: Next version 🔢 165 | run: | 166 | echo "Next version: ${{ needs.build.outputs.next-version }}" 167 | 168 | - name: Generate Javadoc 📝 169 | run: ./gradlew javadoc 170 | 171 | - name: Deploy Javadoc 📚 172 | uses: JamesIves/github-pages-deploy-action@v4 173 | with: 174 | branch: gh-pages 175 | folder: build/docs/javadoc 176 | target-folder: javadoc/${{ needs.build.outputs.next-version }}-SNAPSHOT 177 | commit-message: Publishing javadoc 178 | clean: true 179 | dry-run: false 180 | 181 | 182 | publish-snapshot: 183 | name: 🚀 Publish Snapshot 184 | needs: [build] 185 | if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/master' && github.event_name != 'schedule' && github.repository == 'w3stling/rssreader' }} 186 | runs-on: ubuntu-latest 187 | 188 | steps: 189 | - name: Checkout repository ⚙️ 190 | uses: actions/checkout@v4 191 | with: 192 | # Disabling shallow clone is recommended for improving relevancy of reporting 193 | fetch-depth: 0 194 | 195 | - name: Setup Java 21 ⚙️ 196 | uses: actions/setup-java@v4 197 | with: 198 | java-version: '21' 199 | distribution: 'temurin' 200 | 201 | - name: Setup Gradle dependencies cache ⚙️ 202 | uses: actions/cache@v4 203 | with: 204 | path: ~/.gradle/caches 205 | key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle', '**/*.gradle.kts') }} 206 | 207 | - name: Setup Gradle wrapper cache ⚙️ 208 | uses: actions/cache@v4 209 | with: 210 | path: ~/.gradle/wrapper 211 | key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} 212 | 213 | - name: Validate Gradle wrapper ⚙️ 214 | uses: gradle/actions/wrapper-validation@v4 215 | 216 | - name: Grant execute permission for gradlew ⚙️ 217 | run: chmod +x gradlew 218 | 219 | - name: Next version 🔢 220 | run: | 221 | echo "Next version: ${{ needs.build.outputs.next-version }}" 222 | 223 | - name: Publish snapshot build 🚀 224 | env: 225 | ORG_GRADLE_PROJECT_sonatypePassword: ${{ secrets.SONATYPE_PASSWORD }} 226 | ORG_GRADLE_PROJECT_sonatypeUsername: ${{ secrets.SONATYPE_USERNAME }} 227 | ORG_GRADLE_PROJECT_signingKey: ${{ secrets.PGP_SECRET }} 228 | ORG_GRADLE_PROJECT_signingPassword: ${{ secrets.PGP_PASSPHRASE }} 229 | run: ./gradlew publishToSonatype -Pversion=${{ needs.build.outputs.next-version }}-SNAPSHOT -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run code analysis 2 | 3 | name: CodeQL 4 | 5 | on: 6 | push: 7 | branches: [ master ] 8 | pull_request: 9 | # The branches below must be a subset of the branches above 10 | branches: [ master ] 11 | schedule: 12 | - cron: '42 4 * * 4' 13 | 14 | jobs: 15 | analyze: 16 | name: 🔎 Analyze 17 | runs-on: ubuntu-latest 18 | permissions: 19 | actions: read 20 | contents: read 21 | security-events: write 22 | 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | language: [ 'java' ] 27 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 28 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 29 | 30 | steps: 31 | - name: Checkout repository ⚙️ 32 | uses: actions/checkout@v4 33 | 34 | # Initializes the CodeQL tools for scanning. 35 | - name: Initialize CodeQL 36 | uses: github/codeql-action/init@v3 37 | with: 38 | languages: ${{ matrix.language }} 39 | # If you wish to specify custom queries, you can do so here or in a config file. 40 | # By default, queries listed here will override any specified in a config file. 41 | # Prefix the list here with "+" to use these queries and those in the config file. 42 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 43 | 44 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 45 | # If this step fails, then you should remove it and run the build manually (see below) 46 | #- name: Autobuild 47 | # uses: github/codeql-action/autobuild@v3 48 | 49 | # ℹ️ Command-line programs to run using the OS shell. 50 | # 📚 https://git.io/JvXDl 51 | 52 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 53 | # and modify them (or add more) to build your code if your project 54 | # uses a compiled language 55 | 56 | - name: Autobuild 57 | run: ./gradlew clean build -x test 58 | 59 | - name: Perform CodeQL analysis 60 | uses: github/codeql-action/analyze@v3 61 | -------------------------------------------------------------------------------- /.github/workflows/pr-agent.yml: -------------------------------------------------------------------------------- 1 | # This workflow will review pull request 2 | 3 | name: 🤖 Pull Request Review 4 | 5 | on: 6 | pull_request: 7 | types: [opened, reopened, ready_for_review] 8 | issue_comment: 9 | jobs: 10 | pr_agent_job: 11 | if: ${{ github.event.sender.type != 'Bot' }} 12 | runs-on: ubuntu-latest 13 | permissions: 14 | issues: write 15 | pull-requests: write 16 | contents: write 17 | name: Run PR agent on every pull request, respond to user comments 18 | steps: 19 | - name: PR Agent action step 20 | id: pragent 21 | uses: Codium-ai/pr-agent@main 22 | env: 23 | OPENAI_KEY: ${{ secrets.OPENAI_KEY }} 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | -------------------------------------------------------------------------------- /.github/workflows/pr-check.yml: -------------------------------------------------------------------------------- 1 | # This workflow will check pull request and run tests 2 | 3 | name: 🛂 Pull Request Check 4 | 5 | on: 6 | pull_request: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | pr-labler: 12 | permissions: 13 | contents: read 14 | pull-requests: write 15 | name: 🏷️ PR Labeler 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/labeler@v5 19 | 20 | build: 21 | name: 🛂 Pull Request Check 22 | timeout-minutes: 20 23 | runs-on: ubuntu-latest 24 | permissions: 25 | checks: write 26 | pull-requests: write 27 | 28 | steps: 29 | - name: Checkout repository ⚙️ 30 | uses: actions/checkout@v4 31 | with: 32 | # Disabling shallow clone is recommended for improving relevancy of reporting 33 | fetch-depth: 0 34 | 35 | - name: Setup Java 21 ⚙️ 36 | uses: actions/setup-java@v4 37 | with: 38 | java-version: '21' 39 | distribution: 'temurin' 40 | 41 | - name: Setup Gradle dependencies cache ⚙️ 42 | uses: actions/cache@v4 43 | with: 44 | path: ~/.gradle/caches 45 | key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle', '**/*.gradle.kts') }} 46 | 47 | - name: Setup Gradle wrapper cache ⚙️ 48 | uses: actions/cache@v4 49 | with: 50 | path: ~/.gradle/wrapper 51 | key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} 52 | 53 | - name: Validate Gradle wrapper 🔎 54 | uses: gradle/actions/wrapper-validation@v4 55 | 56 | - name: Build and test 🏗 57 | run: ./gradlew test jacocoTestReport sonar 58 | env: 59 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 60 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 61 | 62 | - name: Publish Test Results 🚦 63 | uses: EnricoMi/publish-unit-test-result-action@v2 64 | if: always() 65 | with: 66 | files: | 67 | build/test-results/test/*.xml 68 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | # This workflow will publish the release to Maven Central Repository 2 | 3 | name: 🚀 Publish Release 4 | 5 | on: 6 | release: 7 | types: [published] 8 | 9 | jobs: 10 | build: 11 | name: 🏗️ Build Release 12 | permissions: 13 | contents: write 14 | runs-on: ubuntu-latest 15 | if: github.repository == 'w3stling/rssreader' 16 | 17 | steps: 18 | - name: Checkout repository ⚙️ 19 | uses: actions/checkout@v4 20 | with: 21 | # Disabling shallow clone is recommended for improving relevancy of reporting 22 | fetch-depth: 0 23 | 24 | - name: Setup Java 21 ⚙️ 25 | uses: actions/setup-java@v4 26 | with: 27 | java-version: '21' 28 | distribution: 'temurin' 29 | 30 | - name: Setup Gradle dependencies cache ⚙️ 31 | uses: actions/cache@v4 32 | with: 33 | path: ~/.gradle/caches 34 | key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle', '**/*.gradle.kts') }} 35 | 36 | - name: Setup Gradle wrapper cache ⚙️ 37 | uses: actions/cache@v4 38 | with: 39 | path: ~/.gradle/wrapper 40 | key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} 41 | 42 | - name: Validate Gradle wrapper ⚙️ 43 | uses: gradle/actions/wrapper-validation@v4 44 | 45 | - name: Version number 🔢 46 | run: | 47 | NEW_VERSION=$(echo "${GITHUB_REF}" | cut -d "/" -f3 | cut -c2-) 48 | echo "New version: ${NEW_VERSION}" 49 | echo "new_version=${NEW_VERSION}" >> $GITHUB_ENV 50 | echo "year=$(date +%Y)" >> $GITHUB_ENV 51 | 52 | - name: Update README.md 📝 53 | uses: bluwy/substitute-string-action@v3 54 | with: 55 | _input-file: './.github/README-template.md' 56 | _output-file: ./README.md 57 | _format-key: '%%key%%' 58 | env: 59 | INPUT_VERSION: ${{ env.new_version }} 60 | INPUT_YEAR: ${{ env.year }} 61 | 62 | - name: Commit README.md 63 | uses: stefanzweifel/git-auto-commit-action@v5 64 | id: auto-commit-action-readme 65 | with: 66 | commit_message: Update version to ${{ env.new_version }} 67 | file_pattern: README.md 68 | branch: master 69 | 70 | - name: "README.md - changes have been detected 🔍" 71 | if: steps.auto-commit-action-readme.outputs.changes_detected == 'true' 72 | run: echo "Updated README.md with release version ${{ env.new_version }} ✅" 73 | 74 | - name: Build release 🏗️ 75 | run: ./gradlew clean build javadoc -x test 76 | 77 | 78 | documentation: 79 | name: 📚 Publish Javadoc 80 | needs: [build] 81 | permissions: 82 | contents: write 83 | runs-on: ubuntu-latest 84 | 85 | steps: 86 | - name: Checkout repository ⚙️ 87 | uses: actions/checkout@v4 88 | with: 89 | # Disabling shallow clone is recommended for improving relevancy of reporting 90 | fetch-depth: 0 91 | 92 | - name: Setup Java 21 ⚙️ 93 | uses: actions/setup-java@v4 94 | with: 95 | java-version: '21' 96 | distribution: 'temurin' 97 | 98 | - name: Setup Gradle dependencies cache ⚙️ 99 | uses: actions/cache@v4 100 | with: 101 | path: ~/.gradle/caches 102 | key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle', '**/*.gradle.kts') }} 103 | 104 | - name: Setup Gradle wrapper cache ⚙️ 105 | uses: actions/cache@v4 106 | with: 107 | path: ~/.gradle/wrapper 108 | key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} 109 | 110 | - name: Validate Gradle wrapper ⚙️ 111 | uses: gradle/actions/wrapper-validation@v4 112 | 113 | - name: Version number 🔢 114 | run: | 115 | NEW_VERSION=$(echo "${GITHUB_REF}" | cut -d "/" -f3 | cut -c2-) 116 | echo "New version: ${NEW_VERSION}" 117 | echo "new_version=${NEW_VERSION}" >> $GITHUB_ENV 118 | echo "year=$(date +%Y)" >> $GITHUB_ENV 119 | 120 | - name: Generate Javadoc 📝 121 | run: ./gradlew javadoc 122 | 123 | - name: Deploy Javadoc 📚 124 | uses: JamesIves/github-pages-deploy-action@v4 125 | with: 126 | branch: gh-pages 127 | folder: build/docs/javadoc 128 | target-folder: javadoc/${{ env.new_version }} 129 | commit-message: Publishing javadoc 130 | clean: true 131 | dry-run: false 132 | 133 | 134 | publish-release: 135 | needs: [build] 136 | name: 🚀 Publish Release 137 | permissions: 138 | contents: write 139 | timeout-minutes: 30 140 | runs-on: ubuntu-latest 141 | 142 | steps: 143 | - name: Checkout repository ⚙️ 144 | uses: actions/checkout@v4 145 | with: 146 | # Disabling shallow clone is recommended for improving relevancy of reporting 147 | fetch-depth: 0 148 | 149 | - name: Setup Java 21 ⚙️ 150 | uses: actions/setup-java@v4 151 | with: 152 | java-version: '21' 153 | distribution: 'temurin' 154 | 155 | - name: Setup Gradle dependencies cache ⚙️ 156 | uses: actions/cache@v4 157 | with: 158 | path: ~/.gradle/caches 159 | key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle', '**/*.gradle.kts') }} 160 | 161 | - name: Setup Gradle wrapper cache ⚙️ 162 | uses: actions/cache@v4 163 | with: 164 | path: ~/.gradle/wrapper 165 | key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} 166 | 167 | - name: Validate Gradle wrapper ⚙️ 168 | uses: gradle/actions/wrapper-validation@v4 169 | 170 | - name: Version number 🔢 171 | run: | 172 | NEW_VERSION=$(echo "${GITHUB_REF}" | cut -d "/" -f3 | cut -c2-) 173 | echo "New version: ${NEW_VERSION}" 174 | echo "new_version=${NEW_VERSION}" >> $GITHUB_ENV 175 | echo "year=$(date +%Y)" >> $GITHUB_ENV 176 | 177 | - name: Publish release build 🚀 178 | env: 179 | ORG_GRADLE_PROJECT_sonatypePassword: ${{ secrets.SONATYPE_PASSWORD }} 180 | ORG_GRADLE_PROJECT_sonatypeUsername: ${{ secrets.SONATYPE_USERNAME }} 181 | ORG_GRADLE_PROJECT_signingKey: ${{ secrets.PGP_SECRET }} 182 | ORG_GRADLE_PROJECT_signingPassword: ${{ secrets.PGP_PASSPHRASE }} 183 | run: | 184 | ./gradlew publishToSonatype closeAndReleaseSonatypeStagingRepository -Pversion=${{ env.new_version }} 185 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | # This workflow will create a draft release and update release notes 2 | 3 | name: 📝 Release Drafter 4 | 5 | on: 6 | push: 7 | # branches to consider in the event; optional, defaults to all 8 | branches: 9 | - master 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | update_release_draft: 16 | name: 🗒️ Update Release Draft 17 | permissions: 18 | # write permission is required to create a github release 19 | contents: write 20 | runs-on: ubuntu-latest 21 | steps: 22 | # Drafts your next Release notes as Pull Requests are merged into "master" 23 | - uses: release-drafter/release-drafter@v6 24 | # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /build/ 3 | 4 | # Ignore Gradle GUI config 5 | gradle-app.setting 6 | 7 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 8 | !gradle-wrapper.jar 9 | 10 | # Cache of project 11 | .gradletasknamecache 12 | 13 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 14 | # gradle/wrapper/gradle-wrapper.properties 15 | 16 | # IDEA 17 | # ---- 18 | .idea 19 | .shelf 20 | /*.iml 21 | /*.ipr 22 | /*.iws 23 | /buildSrc/.idea 24 | /buildSrc/.shelf 25 | /buildSrc/*.iml 26 | /buildSrc/*.ipr 27 | /buildSrc/*.iws 28 | /buildSrc/out 29 | /buildSrc/subprojects/*/*.iml 30 | /buildSrc/subprojects/*/out 31 | /out 32 | /subprojects/*/*.iml 33 | /subprojects/*/out 34 | 35 | 36 | # Eclipse 37 | # ------- 38 | *.classpath 39 | *.project 40 | *.settings 41 | /bin 42 | /subprojects/*/bin 43 | atlassian-ide-plugin.xml 44 | .metadata/ 45 | 46 | 47 | # Emacs 48 | # ----- 49 | *~ 50 | \#*\# 51 | .\#* 52 | 53 | 54 | # macOS 55 | # ---- 56 | .DS_Store 57 | 58 | 59 | # HPROF 60 | # ----- 61 | *.hprof 62 | 63 | 64 | # Compiled class file 65 | # ------------------- 66 | *.class 67 | 68 | 69 | # Logs 70 | # ---- 71 | /*.log 72 | 73 | 74 | # Package Files 75 | # ------------- 76 | *.jar 77 | *.war 78 | *.ear 79 | 80 | 81 | # generated files 82 | # --------------- 83 | bin/ 84 | gen/ 85 | 86 | 87 | # Virtual machine crash logs 88 | # -------------------------- 89 | hs_err_pid* 90 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Apptastic Software 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | RSS Reader 2 | ========== 3 | 4 | [![Build](https://github.com/w3stling/rssreader/actions/workflows/build.yml/badge.svg)](https://github.com/w3stling/rssreader/actions/workflows/build.yml) 5 | [![Download](https://img.shields.io/badge/download-3.9.3-brightgreen.svg)](https://central.sonatype.com/artifact/com.apptasticsoftware/rssreader/3.9.3/overview) 6 | [![Javadoc](https://img.shields.io/badge/javadoc-3.9.3-blue.svg)](https://w3stling.github.io/rssreader/javadoc/3.9.3) 7 | [![License](http://img.shields.io/:license-MIT-blue.svg?style=flat-round)](http://apptastic-software.mit-license.org) 8 | [![CodeQL](https://github.com/w3stling/rssreader/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/w3stling/rssreader/actions/workflows/codeql-analysis.yml) 9 | [![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=w3stling_rssreader&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=w3stling_rssreader) 10 | [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=w3stling_rssreader&metric=coverage)](https://sonarcloud.io/summary/new_code?id=w3stling_rssreader) 11 | [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=w3stling_rssreader&metric=bugs)](https://sonarcloud.io/summary/new_code?id=w3stling_rssreader) 12 | [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=w3stling_rssreader&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=w3stling_rssreader) 13 | [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=w3stling_rssreader&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=w3stling_rssreader) 14 | 15 | > [!NOTE] 16 | > From version 3.0.0: 17 | > * New Java package name 18 | > * New group ID in Maven / Gradle dependency declaration 19 | > * Moved repository from `JCenter` to `Maven Central Repository` 20 | 21 | RSS Reader is a simple Java library for reading RSS and Atom feeds. It has zero 3rd party dependencies, a low memory footprint and can process large feeds. Requires at minimum Java 11. 22 | 23 | RSS (Rich Site Summary) is a type of web feed which allows users to access updates to online content in a 24 | standardized, computer-readable format. It removes the need for the user to manually 25 | check the website for new content. 26 | 27 | 28 | Examples 29 | -------- 30 | ### Read RSS feed 31 | Reads from a RSS (or Atom) feed. 32 | ```java 33 | RssReader rssReader = new RssReader(); 34 | List items = rssReader.read(URL) 35 | .toList(); 36 | ``` 37 | 38 | Extract all items that contains the word football in the title. 39 | ```java 40 | RssReader reader = new RssReader(); 41 | Stream rssFeed = reader.read(URL); 42 | List footballArticles = rssFeed.filter(i -> i.getTitle().equals(Optional.of("football"))) 43 | .toList(); 44 | ``` 45 | 46 | ### Read feed from a file 47 | Reading from file using InputStream 48 | ```java 49 | InputStream inputStream = new FileInputStream("/path/to/file"); 50 | List items = new RssReader().read(inputStream); 51 | .toList(); 52 | ``` 53 | 54 | Reading from file using file URI 55 | ```java 56 | List items = new RssReader().read("file:/path/to/file"); 57 | .toList(); 58 | ``` 59 | 60 | 61 | ### Read from multiple feeds 62 | Read from multiple feeds into a single stream of items sorted in descending (newest first) publication date order and prints the title. 63 | ```java 64 | List urls = List.of(URL1, URL2, URL3, URL4, URL5); 65 | new RssReader().read(urls) 66 | .sorted() 67 | .map(Item::getTitle) 68 | .forEach(System.out::println); 69 | ``` 70 | 71 | To change sort order to ascending (oldest first) publication date 72 | ```java 73 | .sorted(ItemComparator.oldestPublishedItemFirst()) 74 | ``` 75 | For sorting on updated date instead of publication date 76 | ```java 77 | .sorted(ItemComparator.newestUpdatedItemFirst()) 78 | .sorted(ItemComparator.oldestUpdatedItemFirst()) 79 | ``` 80 | 81 | 82 | ### Podcast / iTunes module 83 | Use iTunes module for extracting data from [Podcast][4] specific tags and attributes. 84 | ```java 85 | List items = new ItunesRssReader().read(URL) 86 | .toList(); 87 | ``` 88 | 89 | ### Custom RSS / Atom feed extensions 90 | Support for mapping custom tags and attributes in feed to item and channel object. 91 | ```java 92 | List items = new RssReader() 93 | .addItemExtension("dc:creator", Item::setAuthor) 94 | .addItemExtension("dc:date", Item::setPubDate) 95 | .read("https://lwn.net/headlines/rss") 96 | .toList(); 97 | ``` 98 | 99 | Download 100 | -------- 101 | 102 | Download [the latest JAR][1] or grab via [Maven][2] or [Gradle][3]. 103 | 104 | ### Maven setup 105 | Add dependency declaration: 106 | ```xml 107 | 108 | ... 109 | 110 | 111 | com.apptasticsoftware 112 | rssreader 113 | 3.9.3 114 | 115 | 116 | ... 117 | 118 | ``` 119 | 120 | ### Gradle setup 121 | Add dependency declaration: 122 | ```groovy 123 | dependencies { 124 | implementation 'com.apptasticsoftware:rssreader:3.9.3' 125 | } 126 | ``` 127 | 128 | 129 | Markup Validation Services 130 | ------- 131 | Useful links for validating feeds 132 | 133 | ### RSS / Atom 134 | https://validator.w3.org/feed/
135 | https://www.feedvalidator.org/check.cgi 136 | 137 | ### Podcast / iTunes 138 | https://podba.se/validate/
139 | https://www.castfeedvalidator.com/ 140 | 141 | 142 | 143 | License 144 | ------- 145 | 146 | MIT License 147 | 148 | Copyright (c) 2025, Apptastic Software 149 | 150 | Permission is hereby granted, free of charge, to any person obtaining a copy 151 | of this software and associated documentation files (the "Software"), to deal 152 | in the Software without restriction, including without limitation the rights 153 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 154 | copies of the Software, and to permit persons to whom the Software is 155 | furnished to do so, subject to the following conditions: 156 | 157 | The above copyright notice and this permission notice shall be included in all 158 | copies or substantial portions of the Software. 159 | 160 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 161 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 162 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 163 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 164 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 165 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 166 | SOFTWARE. 167 | 168 | 169 | [1]: https://central.sonatype.com/artifact/com.apptasticsoftware/rssreader/3.9.3/overview 170 | [2]: https://maven.apache.org 171 | [3]: https://gradle.org 172 | [4]: https://help.apple.com/itc/podcasts_connect/#/itcb54353390 -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'java-library' 3 | id 'jacoco' 4 | id 'signing' 5 | id 'maven-publish' 6 | id 'biz.aQute.bnd.builder' version '7.1.0' 7 | id 'io.github.gradle-nexus.publish-plugin' version '2.0.0' 8 | id 'org.sonarqube' version '6.2.0.5505' 9 | } 10 | 11 | group = 'com.apptasticsoftware' 12 | version = "${version}" 13 | 14 | repositories { 15 | mavenCentral() 16 | } 17 | 18 | dependencies { 19 | testImplementation("org.junit.jupiter:junit-jupiter:5.12.2") 20 | testRuntimeOnly("org.junit.platform:junit-platform-launcher") 21 | testImplementation('com.github.npathai:hamcrest-optional:2.0.0') 22 | testImplementation('org.hamcrest:hamcrest:3.0') 23 | testImplementation('org.mockito:mockito-core:5.17.0') 24 | testImplementation('nl.jqno.equalsverifier:equalsverifier:3.19.3') 25 | } 26 | 27 | test { 28 | useJUnitPlatform() 29 | testLogging { 30 | events "passed", "skipped", "failed" 31 | } 32 | } 33 | 34 | java { 35 | sourceCompatibility = JavaVersion.VERSION_11 36 | targetCompatibility = JavaVersion.VERSION_11 37 | withJavadocJar() 38 | withSourcesJar() 39 | } 40 | 41 | ext.moduleName = 'com.apptasticsoftware.rssreader' 42 | 43 | compileJava { 44 | options.encoding = "UTF-8" 45 | inputs.property('moduleName', moduleName) 46 | doFirst { 47 | options.compilerArgs = [ 48 | '--module-path', classpath.asPath 49 | ] 50 | classpath = files() 51 | } 52 | } 53 | 54 | jacoco { 55 | toolVersion = "0.8.9" 56 | } 57 | 58 | jacocoTestReport { 59 | reports { 60 | xml.required = true 61 | } 62 | } 63 | 64 | jar { 65 | manifest { 66 | attributes( 67 | "Build-Jdk-Spec": java.targetCompatibility, 68 | "Implementation-Title": "RSS Reader", 69 | "Implementation-Version": version, 70 | "Specification-Title": "RSS Reader", 71 | "Specification-Version": version.replace("-SNAPSHOT", ""), 72 | "Automatic-Module-Name": moduleName, 73 | "Bundle-SymbolicName": moduleName, 74 | "Bundle-Description": "Java library for reading RSS and Atom feeds", 75 | "Bundle-License": "https://opensource.org/licenses/MIT", 76 | "Bundle-Name": "RSS Reader", 77 | "Export-Package": "*;-split-package:=merge-first;-noimport:=true", 78 | ) 79 | } 80 | } 81 | 82 | nexusPublishing { 83 | repositories { 84 | sonatype { 85 | nexusUrl.set(uri("https://s01.oss.sonatype.org/service/local/")) 86 | snapshotRepositoryUrl.set(uri("https://s01.oss.sonatype.org/content/repositories/snapshots/")) 87 | } 88 | } 89 | connectTimeout = Duration.ofMinutes(3) 90 | clientTimeout = Duration.ofMinutes(3) 91 | } 92 | 93 | publishing { 94 | publications { 95 | mavenJava(MavenPublication) { 96 | from(components.java) 97 | pom { 98 | name = 'RSS Reader' 99 | description = 'Java library for reading RSS and Atom feeds' 100 | url = 'https://github.com/w3stling/rssreader' 101 | licenses { 102 | license { 103 | name = 'MIT License' 104 | url = 'https://raw.githubusercontent.com/w3stling/rssreader/master/LICENSE' 105 | } 106 | } 107 | developers { 108 | developer { 109 | id = 'w3stling' 110 | name = 'Apptastic Software' 111 | email = 'apptastic.software@gmail.com' 112 | } 113 | } 114 | scm { 115 | url = 'https://github.com/w3stling/rssreader' 116 | connection = 'scm:git://github.com/w3stling/rssreader.git' 117 | developerConnection = 'scm:git:ssh://github.com/w3stling/rssreader.git' 118 | } 119 | } 120 | } 121 | } 122 | } 123 | 124 | signing { 125 | def signingKey = findProperty("signingKey") 126 | def signingPassword = findProperty("signingPassword") 127 | useInMemoryPgpKeys(signingKey, signingPassword) 128 | sign publishing.publications.mavenJava 129 | } 130 | 131 | sonar { 132 | properties { 133 | property "sonar.projectKey", "w3stling_rssreader" 134 | property "sonar.organization", "w3stling" 135 | property "sonar.host.url", "https://sonarcloud.io" 136 | } 137 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3stling/rssreader/36084ff4a0f94a88f8812f1bbe0cc73f94043e28/gradle.properties -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3stling/rssreader/36084ff4a0f94a88f8812f1bbe0cc73f94043e28/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH="\\\"\\\"" 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH= 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'rssreader' 2 | 3 | -------------------------------------------------------------------------------- /src/main/java/com/apptasticsoftware/rssreader/DateTimeParser.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2022, Apptastic Software 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | package com.apptasticsoftware.rssreader; 25 | 26 | import java.time.Instant; 27 | import java.time.ZonedDateTime; 28 | 29 | /** 30 | * For parsing timestamp in channel and items. 31 | */ 32 | public interface DateTimeParser { 33 | 34 | /** 35 | * Converts a timestamp in String format to a ZonedDateTime 36 | * @param timestamp timestamp 37 | * @return ZonedDateTime 38 | */ 39 | ZonedDateTime parse(String timestamp); 40 | 41 | /** 42 | * Converts a timestamp in String format to an Instant 43 | * @param dateTime timestamp 44 | * @return Instant 45 | */ 46 | Instant toInstant(String dateTime); 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/com/apptasticsoftware/rssreader/Enclosure.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2022, Apptastic Software 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | package com.apptasticsoftware.rssreader; 25 | 26 | import java.util.Objects; 27 | import java.util.Optional; 28 | 29 | /** 30 | * Class representing the Enclosure. 31 | */ 32 | public class Enclosure { 33 | private String url; 34 | private String type; 35 | private Long length; 36 | 37 | /** 38 | * Get the URL of enclosure. 39 | * @return url 40 | */ 41 | public String getUrl() { 42 | return url; 43 | } 44 | 45 | /** 46 | * Set the URL of the enclosure. 47 | * @param url URL 48 | */ 49 | public void setUrl(String url) { 50 | this.url = url; 51 | } 52 | 53 | /** 54 | * Get the type of enclosure. 55 | * @return type 56 | */ 57 | public String getType() { 58 | return type; 59 | } 60 | 61 | /** 62 | * Set the type of the enclosure. 63 | * @param type type 64 | */ 65 | public void setType(String type) { 66 | this.type = type; 67 | } 68 | 69 | /** 70 | * Get the length of enclosure. 71 | * @return length 72 | */ 73 | public Optional getLength() { 74 | return Optional.ofNullable(length); 75 | } 76 | 77 | /** 78 | * Set the length of the enclosure. 79 | * @param length length 80 | */ 81 | public void setLength(Long length) { 82 | this.length = length; 83 | } 84 | 85 | @Override 86 | public boolean equals(Object o) { 87 | if (this == o) return true; 88 | if (o == null || getClass() != o.getClass()) return false; 89 | Enclosure enclosure = (Enclosure) o; 90 | return Objects.equals(getUrl(), enclosure.getUrl()) && Objects.equals(getType(), enclosure.getType()) && Objects.equals(getLength(), enclosure.getLength()); 91 | } 92 | 93 | @Override 94 | public int hashCode() { 95 | return Objects.hash(getUrl(), getType(), getLength()); 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /src/main/java/com/apptasticsoftware/rssreader/Image.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2022, Apptastic Software 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | package com.apptasticsoftware.rssreader; 25 | 26 | import java.util.Objects; 27 | import java.util.Optional; 28 | 29 | /** 30 | * Class representing a image in channel. 31 | */ 32 | public class Image { 33 | private String title; 34 | private String link; 35 | private String url; 36 | private String description; 37 | private Integer height; 38 | private Integer width; 39 | 40 | /** 41 | * Get title that describes the image. 42 | * @return title 43 | */ 44 | public String getTitle() { 45 | return title; 46 | } 47 | 48 | /** 49 | * Set title that describes the image. 50 | * @param title title 51 | */ 52 | public void setTitle(String title) { 53 | this.title = title; 54 | } 55 | 56 | /** 57 | * Get the URL of the site. 58 | * @return link 59 | */ 60 | public String getLink() { 61 | return link; 62 | } 63 | 64 | /** 65 | * Set the URL of the site. 66 | * @param link link 67 | */ 68 | public void setLink(String link) { 69 | this.link = link; 70 | } 71 | 72 | /** 73 | * Get the URL of a GIF, JPEG or PNG image that represents the channel. 74 | * @return url to image 75 | */ 76 | public String getUrl() { 77 | return url; 78 | } 79 | 80 | /** 81 | * Set the URL of a GIF, JPEG or PNG image that represents the channel. 82 | * @param url url to image 83 | */ 84 | public void setUrl(String url) { 85 | this.url = url; 86 | } 87 | 88 | /** 89 | * Get the description. 90 | * @return description 91 | */ 92 | public Optional getDescription() { 93 | return Optional.ofNullable(description); 94 | } 95 | 96 | /** 97 | * Set the description. 98 | * @param description description 99 | */ 100 | public void setDescription(String description) { 101 | this.description = description; 102 | } 103 | 104 | /** 105 | * Get the height of the image. 106 | * @return image height 107 | */ 108 | public Optional getHeight() { 109 | return Optional.ofNullable(height); 110 | } 111 | 112 | /** 113 | * Set the height of the image. 114 | * @param height image height 115 | */ 116 | public void setHeight(Integer height) { 117 | this.height = height; 118 | } 119 | 120 | /** 121 | * Get the width of the image. 122 | * @return image width 123 | */ 124 | public Optional getWidth() { 125 | return Optional.ofNullable(width); 126 | } 127 | 128 | /** 129 | * Set the width of the image. 130 | * @param width image width 131 | */ 132 | public void setWidth(Integer width) { 133 | this.width = width; 134 | } 135 | 136 | @Override 137 | public boolean equals(Object o) { 138 | if (this == o) return true; 139 | if (o == null || getClass() != o.getClass()) return false; 140 | Image image = (Image) o; 141 | return Objects.equals(getTitle(), image.getTitle()) && Objects.equals(getLink(), image.getLink()) && 142 | Objects.equals(getUrl(), image.getUrl()) && Objects.equals(getDescription(), image.getDescription()) && 143 | Objects.equals(getHeight(), image.getHeight()) && Objects.equals(getWidth(), image.getWidth()); 144 | } 145 | 146 | @Override 147 | public int hashCode() { 148 | return Objects.hash(getTitle(), getLink(), getUrl(), getDescription(), getHeight(), getWidth()); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/main/java/com/apptasticsoftware/rssreader/RssReader.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2022, Apptastic Software 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | package com.apptasticsoftware.rssreader; 25 | 26 | import java.net.http.HttpClient; 27 | 28 | /** 29 | * Class for reading RSS (Rich Site Summary) and Atom types of web feeds. 30 | */ 31 | public class RssReader extends AbstractRssReader { 32 | 33 | /** 34 | * Constructor 35 | */ 36 | public RssReader() { 37 | super(); 38 | } 39 | 40 | /** 41 | * Constructor 42 | * @param httpClient http client 43 | */ 44 | public RssReader(HttpClient httpClient) { 45 | super(httpClient); 46 | } 47 | 48 | @Override 49 | protected Channel createChannel(DateTimeParser dateTimeParser) { 50 | return new Channel(dateTimeParser); 51 | } 52 | 53 | @Override 54 | protected Item createItem(DateTimeParser dateTimeParser) { 55 | return new Item(dateTimeParser); 56 | } 57 | 58 | } -------------------------------------------------------------------------------- /src/main/java/com/apptasticsoftware/rssreader/internal/DaemonThreadFactory.java: -------------------------------------------------------------------------------- 1 | package com.apptasticsoftware.rssreader.internal; 2 | 3 | import java.util.concurrent.ThreadFactory; 4 | 5 | /** 6 | * Thread factory that creates daemon threads 7 | */ 8 | public class DaemonThreadFactory implements ThreadFactory { 9 | private final String name; 10 | private int counter; 11 | 12 | public DaemonThreadFactory(String name) { 13 | this.name = name; 14 | } 15 | 16 | @Override 17 | public Thread newThread(Runnable r) { 18 | Thread t = new Thread(r, name + "-" + counter++); 19 | t.setDaemon(true); 20 | return t; 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/apptasticsoftware/rssreader/internal/StreamUtil.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024, Apptastic Software 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | package com.apptasticsoftware.rssreader.internal; 25 | 26 | import java.util.Iterator; 27 | import java.util.Spliterator; 28 | import java.util.Spliterators; 29 | import java.util.stream.Stream; 30 | import java.util.stream.StreamSupport; 31 | 32 | import static java.util.Objects.requireNonNull; 33 | 34 | /** 35 | * Internal utility class for working with streams. 36 | */ 37 | public class StreamUtil { 38 | 39 | private StreamUtil() { } 40 | 41 | /** 42 | * Creates a Stream from an Iterator. 43 | * 44 | * @param iterator The Iterator to create the Stream from. 45 | * @param The type of the elements in the Stream. 46 | * @return A Stream created from the Iterator. 47 | */ 48 | public static Stream asStream(Iterator iterator) { 49 | requireNonNull(iterator); 50 | return StreamSupport.stream(Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED), false); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/java/com/apptasticsoftware/rssreader/internal/XMLInputFactorySecurity.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024, Apptastic Software 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | package com.apptasticsoftware.rssreader.internal; 25 | 26 | import javax.xml.stream.XMLInputFactory; 27 | import java.util.Objects; 28 | 29 | /** 30 | * This type exposes helper methods that will help defend against XXE attacks in {@link 31 | * XMLInputFactory}. 32 | * 33 | *

For more on XXE: 34 | * 35 | *

XXE 37 | * OWASP CheatSheet 38 | */ 39 | public class XMLInputFactorySecurity { 40 | 41 | private XMLInputFactorySecurity() {} 42 | 43 | public static XMLInputFactory hardenFactory(final XMLInputFactory factory) { 44 | Objects.requireNonNull(factory); 45 | // disable XML external entity (XXE) processing 46 | factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); 47 | factory.setProperty(XMLInputFactory.SUPPORT_DTD, false); 48 | return factory; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/com/apptasticsoftware/rssreader/internal/stream/AbstractAutoCloseStream.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024, Apptastic Software 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | package com.apptasticsoftware.rssreader.internal.stream; 25 | 26 | import java.util.Objects; 27 | import java.util.concurrent.atomic.AtomicBoolean; 28 | import java.util.function.*; 29 | import java.util.stream.*; 30 | 31 | @SuppressWarnings("javaarchitecture:S7027") 32 | public class AbstractAutoCloseStream> implements AutoCloseable { 33 | private final S stream; 34 | private final AtomicBoolean isClosed; 35 | 36 | AbstractAutoCloseStream(S stream) { 37 | this.stream = Objects.requireNonNull(stream); 38 | this.isClosed = new AtomicBoolean(); 39 | } 40 | 41 | protected S stream() { 42 | return stream; 43 | } 44 | 45 | @Override 46 | public void close() { 47 | if (isClosed.compareAndSet(false,true)) { 48 | stream().close(); 49 | } 50 | } 51 | 52 | R autoClose(Function function) { 53 | try (S s = stream()) { 54 | return function.apply(s); 55 | } 56 | } 57 | 58 | Stream asAutoCloseStream(Stream stream) { 59 | return asAutoCloseStream(stream, AutoCloseStream::new); 60 | } 61 | 62 | IntStream asAutoCloseStream(IntStream stream) { 63 | return asAutoCloseStream(stream, AutoCloseIntStream::new); 64 | } 65 | 66 | LongStream asAutoCloseStream(LongStream stream) { 67 | return asAutoCloseStream(stream, AutoCloseLongStream::new); 68 | } 69 | 70 | DoubleStream asAutoCloseStream(DoubleStream stream) { 71 | return asAutoCloseStream(stream, AutoCloseDoubleStream::new); 72 | } 73 | 74 | private U asAutoCloseStream(U stream, UnaryOperator wrapper) { 75 | if (stream instanceof AbstractAutoCloseStream) { 76 | return stream; 77 | } 78 | return wrapper.apply(stream); 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /src/main/java/com/apptasticsoftware/rssreader/internal/stream/AutoCloseDoubleStream.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024, Apptastic Software 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | package com.apptasticsoftware.rssreader.internal.stream; 25 | 26 | import java.util.DoubleSummaryStatistics; 27 | import java.util.OptionalDouble; 28 | import java.util.PrimitiveIterator; 29 | import java.util.Spliterator; 30 | import java.util.function.*; 31 | import java.util.stream.DoubleStream; 32 | import java.util.stream.IntStream; 33 | import java.util.stream.LongStream; 34 | import java.util.stream.Stream; 35 | 36 | public class AutoCloseDoubleStream extends AbstractAutoCloseStream implements DoubleStream { 37 | 38 | AutoCloseDoubleStream(DoubleStream stream) { 39 | super(stream); 40 | } 41 | 42 | @Override 43 | public DoubleStream filter(DoublePredicate predicate) { 44 | return asAutoCloseStream(stream().filter(predicate)); 45 | } 46 | 47 | @Override 48 | public DoubleStream map(DoubleUnaryOperator mapper) { 49 | return asAutoCloseStream(stream().map(mapper)); 50 | } 51 | 52 | @Override 53 | public Stream mapToObj(DoubleFunction mapper) { 54 | return asAutoCloseStream(stream().mapToObj(mapper)); 55 | } 56 | 57 | @Override 58 | public IntStream mapToInt(DoubleToIntFunction mapper) { 59 | return asAutoCloseStream(stream().mapToInt(mapper)); 60 | } 61 | 62 | @Override 63 | public LongStream mapToLong(DoubleToLongFunction mapper) { 64 | return asAutoCloseStream(stream().mapToLong(mapper)); 65 | } 66 | 67 | @Override 68 | public DoubleStream flatMap(DoubleFunction mapper) { 69 | return asAutoCloseStream(stream().flatMap(mapper)); 70 | } 71 | 72 | @Override 73 | public DoubleStream distinct() { 74 | return asAutoCloseStream(stream().distinct()); 75 | } 76 | 77 | @Override 78 | public DoubleStream sorted() { 79 | return asAutoCloseStream(stream().sorted()); 80 | } 81 | 82 | @SuppressWarnings("java:S3864") 83 | @Override 84 | public DoubleStream peek(DoubleConsumer action) { 85 | return asAutoCloseStream(stream().peek(action)); 86 | } 87 | 88 | @Override 89 | public DoubleStream limit(long maxSize) { 90 | return asAutoCloseStream(stream().limit(maxSize)); 91 | } 92 | 93 | @Override 94 | public DoubleStream skip(long n) { 95 | return asAutoCloseStream(stream().skip(n)); 96 | } 97 | 98 | @Override 99 | public void forEach(DoubleConsumer action) { 100 | autoClose(stream -> { 101 | stream.forEach(action); 102 | return null; 103 | }); 104 | } 105 | 106 | @Override 107 | public void forEachOrdered(DoubleConsumer action) { 108 | autoClose(stream -> { 109 | stream.forEachOrdered(action); 110 | return null; 111 | }); 112 | } 113 | 114 | @Override 115 | public double[] toArray() { 116 | return autoClose(DoubleStream::toArray); 117 | } 118 | 119 | @Override 120 | public double reduce(double identity, DoubleBinaryOperator op) { 121 | return autoClose(stream -> stream.reduce(identity, op)); 122 | } 123 | 124 | @Override 125 | public OptionalDouble reduce(DoubleBinaryOperator op) { 126 | return autoClose(stream -> stream.reduce(op)); 127 | } 128 | 129 | @Override 130 | public R collect(Supplier supplier, ObjDoubleConsumer accumulator, BiConsumer combiner) { 131 | return autoClose(stream -> stream.collect(supplier, accumulator, combiner)); 132 | } 133 | 134 | @Override 135 | public double sum() { 136 | return autoClose(DoubleStream::sum); 137 | } 138 | 139 | @Override 140 | public OptionalDouble min() { 141 | return autoClose(DoubleStream::min); 142 | } 143 | 144 | @Override 145 | public OptionalDouble max() { 146 | return autoClose(DoubleStream::max); 147 | } 148 | 149 | @Override 150 | public long count() { 151 | return autoClose(DoubleStream::count); 152 | } 153 | 154 | @Override 155 | public OptionalDouble average() { 156 | return autoClose(DoubleStream::average); 157 | } 158 | 159 | @Override 160 | public DoubleSummaryStatistics summaryStatistics() { 161 | return autoClose(DoubleStream::summaryStatistics); 162 | } 163 | 164 | @Override 165 | public boolean anyMatch(DoublePredicate predicate) { 166 | return autoClose(stream -> stream.anyMatch(predicate)); 167 | } 168 | 169 | @Override 170 | public boolean allMatch(DoublePredicate predicate) { 171 | return autoClose(stream -> stream.allMatch(predicate)); 172 | } 173 | 174 | @Override 175 | public boolean noneMatch(DoublePredicate predicate) { 176 | return autoClose(stream -> stream.noneMatch(predicate)); 177 | } 178 | 179 | @Override 180 | public OptionalDouble findFirst() { 181 | return autoClose(DoubleStream::findFirst); 182 | } 183 | 184 | @Override 185 | public OptionalDouble findAny() { 186 | return autoClose(DoubleStream::findAny); 187 | } 188 | 189 | @Override 190 | public Stream boxed() { 191 | return asAutoCloseStream(stream().boxed()); 192 | } 193 | 194 | @Override 195 | public DoubleStream sequential() { 196 | return asAutoCloseStream(stream().sequential()); 197 | } 198 | 199 | @Override 200 | public DoubleStream parallel() { 201 | return asAutoCloseStream(stream().parallel()); 202 | } 203 | 204 | @Override 205 | public PrimitiveIterator.OfDouble iterator() { 206 | return stream().iterator(); 207 | } 208 | 209 | @Override 210 | public Spliterator.OfDouble spliterator() { 211 | return stream().spliterator(); 212 | } 213 | 214 | @Override 215 | public boolean isParallel() { 216 | return stream().isParallel(); 217 | } 218 | 219 | @Override 220 | public DoubleStream unordered() { 221 | return asAutoCloseStream(stream().unordered()); 222 | } 223 | 224 | @Override 225 | public DoubleStream onClose(Runnable closeHandler) { 226 | return asAutoCloseStream(stream().onClose(closeHandler)); 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/main/java/com/apptasticsoftware/rssreader/internal/stream/AutoCloseIntStream.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024, Apptastic Software 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | package com.apptasticsoftware.rssreader.internal.stream; 25 | 26 | import java.util.*; 27 | import java.util.function.*; 28 | import java.util.stream.DoubleStream; 29 | import java.util.stream.IntStream; 30 | import java.util.stream.LongStream; 31 | import java.util.stream.Stream; 32 | 33 | public class AutoCloseIntStream extends AbstractAutoCloseStream implements IntStream { 34 | 35 | AutoCloseIntStream(IntStream stream) { 36 | super(stream); 37 | } 38 | 39 | @Override 40 | public IntStream filter(IntPredicate predicate) { 41 | return asAutoCloseStream(stream().filter(predicate)); 42 | } 43 | 44 | @Override 45 | public IntStream map(IntUnaryOperator mapper) { 46 | return asAutoCloseStream(stream().map(mapper)); 47 | } 48 | 49 | @Override 50 | public Stream mapToObj(IntFunction mapper) { 51 | return asAutoCloseStream(stream().mapToObj(mapper)); 52 | } 53 | 54 | @Override 55 | public LongStream mapToLong(IntToLongFunction mapper) { 56 | return asAutoCloseStream(stream().mapToLong(mapper)); 57 | } 58 | 59 | @Override 60 | public DoubleStream mapToDouble(IntToDoubleFunction mapper) { 61 | return asAutoCloseStream(stream().mapToDouble(mapper)); 62 | } 63 | 64 | @Override 65 | public IntStream flatMap(IntFunction mapper) { 66 | return asAutoCloseStream(stream().flatMap(mapper)); 67 | } 68 | 69 | @Override 70 | public IntStream distinct() { 71 | return asAutoCloseStream(stream().distinct()); 72 | } 73 | 74 | @Override 75 | public IntStream sorted() { 76 | return asAutoCloseStream(stream().sorted()); 77 | } 78 | 79 | @SuppressWarnings("java:S3864") 80 | @Override 81 | public IntStream peek(IntConsumer action) { 82 | return asAutoCloseStream(stream().peek(action)); 83 | } 84 | 85 | @Override 86 | public IntStream limit(long maxSize) { 87 | return asAutoCloseStream(stream().limit(maxSize)); 88 | } 89 | 90 | @Override 91 | public IntStream skip(long n) { 92 | return asAutoCloseStream(stream().skip(n)); 93 | } 94 | 95 | @Override 96 | public void forEach(IntConsumer action) { 97 | autoClose(stream -> { 98 | stream.forEach(action); 99 | return null; 100 | }); 101 | } 102 | 103 | @Override 104 | public void forEachOrdered(IntConsumer action) { 105 | autoClose(stream -> { 106 | stream.forEachOrdered(action); 107 | return null; 108 | }); 109 | } 110 | 111 | @Override 112 | public int[] toArray() { 113 | return autoClose(IntStream::toArray); 114 | } 115 | 116 | @Override 117 | public int reduce(int identity, IntBinaryOperator op) { 118 | return autoClose(stream -> stream.reduce(identity, op)); 119 | } 120 | 121 | @Override 122 | public OptionalInt reduce(IntBinaryOperator op) { 123 | return autoClose(stream -> stream.reduce(op)); 124 | } 125 | 126 | @Override 127 | public R collect(Supplier supplier, ObjIntConsumer accumulator, BiConsumer combiner) { 128 | return autoClose(stream -> stream.collect(supplier, accumulator, combiner)); 129 | } 130 | 131 | @Override 132 | public int sum() { 133 | return autoClose(IntStream::sum); 134 | } 135 | 136 | @Override 137 | public OptionalInt min() { 138 | return autoClose(IntStream::min); 139 | } 140 | 141 | @Override 142 | public OptionalInt max() { 143 | return autoClose(IntStream::max); 144 | } 145 | 146 | @Override 147 | public long count() { 148 | return autoClose(IntStream::count); 149 | } 150 | 151 | @Override 152 | public OptionalDouble average() { 153 | return autoClose(IntStream::average); 154 | } 155 | 156 | @Override 157 | public IntSummaryStatistics summaryStatistics() { 158 | return autoClose(IntStream::summaryStatistics); 159 | } 160 | 161 | @Override 162 | public boolean anyMatch(IntPredicate predicate) { 163 | return autoClose(stream -> stream.anyMatch(predicate)); 164 | } 165 | 166 | @Override 167 | public boolean allMatch(IntPredicate predicate) { 168 | return autoClose(stream -> stream.allMatch(predicate)); 169 | } 170 | 171 | @Override 172 | public boolean noneMatch(IntPredicate predicate) { 173 | return autoClose(stream -> stream.noneMatch(predicate)); 174 | } 175 | 176 | @Override 177 | public OptionalInt findFirst() { 178 | return autoClose(IntStream::findFirst); 179 | } 180 | 181 | @Override 182 | public OptionalInt findAny() { 183 | return autoClose(IntStream::findAny); 184 | } 185 | 186 | @Override 187 | public LongStream asLongStream() { 188 | return asAutoCloseStream(stream().asLongStream()); 189 | } 190 | 191 | @Override 192 | public DoubleStream asDoubleStream() { 193 | return asAutoCloseStream(stream().asDoubleStream()); 194 | } 195 | 196 | @Override 197 | public Stream boxed() { 198 | return asAutoCloseStream(stream().boxed()); 199 | } 200 | 201 | @Override 202 | public IntStream sequential() { 203 | return asAutoCloseStream(stream().sequential()); 204 | } 205 | 206 | @Override 207 | public IntStream parallel() { 208 | return asAutoCloseStream(stream().parallel()); 209 | } 210 | 211 | @Override 212 | public PrimitiveIterator.OfInt iterator() { 213 | return stream().iterator(); 214 | } 215 | 216 | @Override 217 | public Spliterator.OfInt spliterator() { 218 | return stream().spliterator(); 219 | } 220 | 221 | @Override 222 | public boolean isParallel() { 223 | return stream().isParallel(); 224 | } 225 | 226 | @Override 227 | public IntStream unordered() { 228 | return asAutoCloseStream(stream().unordered()); 229 | } 230 | 231 | @Override 232 | public IntStream onClose(Runnable closeHandler) { 233 | return asAutoCloseStream(stream().onClose(closeHandler)); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/main/java/com/apptasticsoftware/rssreader/internal/stream/AutoCloseLongStream.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024, Apptastic Software 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | package com.apptasticsoftware.rssreader.internal.stream; 25 | 26 | import java.util.*; 27 | import java.util.function.*; 28 | import java.util.stream.DoubleStream; 29 | import java.util.stream.IntStream; 30 | import java.util.stream.LongStream; 31 | import java.util.stream.Stream; 32 | 33 | public class AutoCloseLongStream extends AbstractAutoCloseStream implements LongStream { 34 | 35 | AutoCloseLongStream(LongStream stream) { 36 | super(stream); 37 | } 38 | 39 | @Override 40 | public LongStream filter(LongPredicate predicate) { 41 | return asAutoCloseStream(stream().filter(predicate)); 42 | } 43 | 44 | @Override 45 | public LongStream map(LongUnaryOperator mapper) { 46 | return asAutoCloseStream(stream().map(mapper)); 47 | } 48 | 49 | @Override 50 | public Stream mapToObj(LongFunction mapper) { 51 | return asAutoCloseStream(stream().mapToObj(mapper)); 52 | } 53 | 54 | @Override 55 | public IntStream mapToInt(LongToIntFunction mapper) { 56 | return asAutoCloseStream(stream().mapToInt(mapper)); 57 | } 58 | 59 | @Override 60 | public DoubleStream mapToDouble(LongToDoubleFunction mapper) { 61 | return asAutoCloseStream(stream().mapToDouble(mapper)); 62 | } 63 | 64 | @Override 65 | public LongStream flatMap(LongFunction mapper) { 66 | return asAutoCloseStream(stream().flatMap(mapper)); 67 | } 68 | 69 | @Override 70 | public LongStream distinct() { 71 | return asAutoCloseStream(stream().distinct()); 72 | } 73 | 74 | @Override 75 | public LongStream sorted() { 76 | return asAutoCloseStream(stream().sorted()); 77 | } 78 | 79 | @SuppressWarnings("java:S3864") 80 | @Override 81 | public LongStream peek(LongConsumer action) { 82 | return asAutoCloseStream(stream().peek(action)); 83 | } 84 | 85 | @Override 86 | public LongStream limit(long maxSize) { 87 | return asAutoCloseStream(stream().limit(maxSize)); 88 | } 89 | 90 | @Override 91 | public LongStream skip(long n) { 92 | return asAutoCloseStream(stream().skip(n)); 93 | } 94 | 95 | @Override 96 | public void forEach(LongConsumer action) { 97 | autoClose(stream -> { 98 | stream.forEach(action); 99 | return null; 100 | }); 101 | } 102 | 103 | @Override 104 | public void forEachOrdered(LongConsumer action) { 105 | autoClose(stream -> { 106 | stream.forEachOrdered(action); 107 | return null; 108 | }); 109 | } 110 | 111 | @Override 112 | public long[] toArray() { 113 | return autoClose(LongStream::toArray); 114 | } 115 | 116 | @Override 117 | public long reduce(long identity, LongBinaryOperator op) { 118 | return autoClose(stream -> stream.reduce(identity, op)); 119 | } 120 | 121 | @Override 122 | public OptionalLong reduce(LongBinaryOperator op) { 123 | return autoClose(stream -> stream.reduce(op)); 124 | } 125 | 126 | @Override 127 | public R collect(Supplier supplier, ObjLongConsumer accumulator, BiConsumer combiner) { 128 | return autoClose(stream -> stream.collect(supplier, accumulator, combiner)); 129 | } 130 | 131 | @Override 132 | public long sum() { 133 | return autoClose(LongStream::sum); 134 | } 135 | 136 | @Override 137 | public OptionalLong min() { 138 | return autoClose(LongStream::min); 139 | } 140 | 141 | @Override 142 | public OptionalLong max() { 143 | return autoClose(LongStream::max); 144 | } 145 | 146 | @Override 147 | public long count() { 148 | return autoClose(LongStream::count); 149 | } 150 | 151 | @Override 152 | public OptionalDouble average() { 153 | return autoClose(LongStream::average); 154 | } 155 | 156 | @Override 157 | public LongSummaryStatistics summaryStatistics() { 158 | return autoClose(LongStream::summaryStatistics); 159 | } 160 | 161 | @Override 162 | public boolean anyMatch(LongPredicate predicate) { 163 | return autoClose(stream -> stream.anyMatch(predicate)); 164 | } 165 | 166 | @Override 167 | public boolean allMatch(LongPredicate predicate) { 168 | return autoClose(stream -> stream.allMatch(predicate)); 169 | } 170 | 171 | @Override 172 | public boolean noneMatch(LongPredicate predicate) { 173 | return autoClose(stream -> stream.noneMatch(predicate)); 174 | } 175 | 176 | @Override 177 | public OptionalLong findFirst() { 178 | return autoClose(LongStream::findFirst); 179 | } 180 | 181 | @Override 182 | public OptionalLong findAny() { 183 | return autoClose(LongStream::findAny); 184 | } 185 | 186 | @Override 187 | public DoubleStream asDoubleStream() { 188 | return asAutoCloseStream(stream().asDoubleStream()); 189 | } 190 | 191 | @Override 192 | public Stream boxed() { 193 | return asAutoCloseStream(stream().boxed()); 194 | } 195 | 196 | @Override 197 | public LongStream sequential() { 198 | return asAutoCloseStream(stream().sequential()); 199 | } 200 | 201 | @Override 202 | public LongStream parallel() { 203 | return asAutoCloseStream(stream().parallel()); 204 | } 205 | 206 | @Override 207 | public PrimitiveIterator.OfLong iterator() { 208 | return stream().iterator(); 209 | } 210 | 211 | @Override 212 | public Spliterator.OfLong spliterator() { 213 | return stream().spliterator(); 214 | } 215 | 216 | @Override 217 | public boolean isParallel() { 218 | return stream().isParallel(); 219 | } 220 | 221 | @Override 222 | public LongStream unordered() { 223 | return asAutoCloseStream(stream().unordered()); 224 | } 225 | 226 | @Override 227 | public LongStream onClose(Runnable closeHandler) { 228 | return asAutoCloseStream(stream().onClose(closeHandler)); 229 | } 230 | 231 | } 232 | -------------------------------------------------------------------------------- /src/main/java/com/apptasticsoftware/rssreader/internal/stream/AutoCloseStream.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024, Apptastic Software 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | package com.apptasticsoftware.rssreader.internal.stream; 25 | 26 | import java.util.*; 27 | import java.util.function.*; 28 | import java.util.stream.*; 29 | 30 | /** 31 | * A Stream that automatically calls its {@link #close()} method after a terminating operation, such as limit(), forEach(), or collect(), has been executed. 32 | *

33 | * This class is useful for working with streams that have resources that need to be closed, such as file streams or network connections. 34 | *

35 | * If the {@link #close()} method is called manually, the stream will be closed and any subsequent operations will throw an {@link IllegalStateException}. 36 | *

37 | * The {@link #iterator()} and {@link #spliterator()} methods are not supported by this class. 38 | */ 39 | @SuppressWarnings("javaarchitecture:S7027") 40 | public class AutoCloseStream extends AbstractAutoCloseStream> implements Stream { 41 | 42 | AutoCloseStream(Stream stream) { 43 | super(stream); 44 | } 45 | 46 | /** 47 | * Creates a new AutoCloseStream from the given stream. 48 | * @param stream the stream to wrap 49 | * @return a new AutoCloseStream 50 | */ 51 | public static AutoCloseStream of(Stream stream) { 52 | Objects.requireNonNull(stream); 53 | return new AutoCloseStream<>(stream); 54 | } 55 | 56 | @Override 57 | public Stream filter(Predicate predicate) { 58 | return asAutoCloseStream(stream().filter(predicate)); 59 | } 60 | 61 | @Override 62 | public Stream map(Function mapper) { 63 | return asAutoCloseStream(stream().map(mapper)); 64 | } 65 | 66 | @Override 67 | public IntStream mapToInt(ToIntFunction mapper) { 68 | return asAutoCloseStream(stream().mapToInt(mapper)); 69 | } 70 | 71 | @Override 72 | public LongStream mapToLong(ToLongFunction mapper) { 73 | return asAutoCloseStream(stream().mapToLong(mapper)); 74 | } 75 | 76 | @Override 77 | public DoubleStream mapToDouble(ToDoubleFunction mapper) { 78 | return asAutoCloseStream(stream().mapToDouble(mapper)); 79 | } 80 | 81 | @Override 82 | public Stream flatMap(Function> mapper) { 83 | return asAutoCloseStream(stream().flatMap(mapper)); 84 | } 85 | 86 | @Override 87 | public IntStream flatMapToInt(Function mapper) { 88 | return asAutoCloseStream(stream().flatMapToInt(mapper)); 89 | } 90 | 91 | @Override 92 | public LongStream flatMapToLong(Function mapper) { 93 | return asAutoCloseStream(stream().flatMapToLong(mapper)); 94 | } 95 | 96 | @Override 97 | public DoubleStream flatMapToDouble(Function mapper) { 98 | return asAutoCloseStream(stream().flatMapToDouble(mapper)); 99 | } 100 | 101 | @Override 102 | public Stream distinct() { 103 | return asAutoCloseStream(stream().distinct()); 104 | } 105 | 106 | @Override 107 | public Stream sorted() { 108 | return asAutoCloseStream(stream().sorted()); 109 | } 110 | 111 | @Override 112 | public Stream sorted(Comparator comparator) { 113 | return asAutoCloseStream(stream().sorted(comparator)); 114 | } 115 | 116 | @SuppressWarnings("java:S3864") 117 | @Override 118 | public Stream peek(Consumer action) { 119 | return asAutoCloseStream(stream().peek(action)); 120 | } 121 | 122 | @Override 123 | public Stream limit(long maxSize) { 124 | return asAutoCloseStream(stream().limit(maxSize)); 125 | } 126 | 127 | @Override 128 | public Stream skip(long n) { 129 | return asAutoCloseStream(stream().skip(n)); 130 | } 131 | 132 | @Override 133 | public void forEach(Consumer action) { 134 | autoClose(stream -> { 135 | stream.forEach(action); 136 | return null; 137 | }); 138 | } 139 | 140 | @Override 141 | public void forEachOrdered(Consumer action) { 142 | autoClose(stream -> { 143 | stream.forEachOrdered(action); 144 | return null; 145 | }); 146 | } 147 | 148 | @Override 149 | public Object[] toArray() { 150 | return autoClose(Stream::toArray); 151 | } 152 | 153 | @Override 154 | public A[] toArray(IntFunction generator) { 155 | return autoClose(stream -> stream.toArray(generator)); 156 | } 157 | 158 | @Override 159 | public T reduce(T identity, BinaryOperator accumulator) { 160 | return autoClose(stream -> stream.reduce(identity, accumulator)); 161 | } 162 | 163 | @Override 164 | public Optional reduce(BinaryOperator accumulator) { 165 | return autoClose(stream -> stream.reduce(accumulator)); 166 | } 167 | 168 | @Override 169 | public U reduce(U identity, BiFunction accumulator, BinaryOperator combiner) { 170 | return autoClose(stream -> stream.reduce(identity, accumulator, combiner)); 171 | } 172 | 173 | @Override 174 | public R collect(Supplier supplier, BiConsumer accumulator, BiConsumer combiner) { 175 | return autoClose(stream -> stream.collect(supplier, accumulator, combiner)); 176 | } 177 | 178 | @Override 179 | public R collect(Collector collector) { 180 | return autoClose(stream -> stream.collect(collector)); 181 | } 182 | 183 | @Override 184 | public Optional min(Comparator comparator) { 185 | return autoClose(stream -> stream.min(comparator)); 186 | } 187 | 188 | @Override 189 | public Optional max(Comparator comparator) { 190 | return autoClose(stream -> stream.max(comparator)); 191 | } 192 | 193 | @Override 194 | public long count() { 195 | return autoClose(Stream::count); 196 | } 197 | 198 | @Override 199 | public boolean anyMatch(Predicate predicate) { 200 | return autoClose(stream -> stream.anyMatch(predicate)); 201 | } 202 | 203 | @Override 204 | public boolean allMatch(Predicate predicate) { 205 | return autoClose(stream -> stream.allMatch(predicate)); 206 | } 207 | 208 | @Override 209 | public boolean noneMatch(Predicate predicate) { 210 | return autoClose(stream -> stream.noneMatch(predicate)); 211 | } 212 | 213 | @Override 214 | public Optional findFirst() { 215 | return autoClose(Stream::findFirst); 216 | } 217 | 218 | @Override 219 | public Optional findAny() { 220 | return autoClose(Stream::findAny); 221 | } 222 | 223 | @Override 224 | public Iterator iterator() { 225 | return stream().iterator(); 226 | } 227 | 228 | @Override 229 | public Spliterator spliterator() { 230 | return stream().spliterator(); 231 | } 232 | 233 | @Override 234 | public boolean isParallel() { 235 | return stream().isParallel(); 236 | } 237 | 238 | @Override 239 | public Stream sequential() { 240 | return asAutoCloseStream(stream().sequential()); 241 | } 242 | 243 | @Override 244 | public Stream parallel() { 245 | return asAutoCloseStream(stream().parallel()); 246 | } 247 | 248 | @Override 249 | public Stream unordered() { 250 | return asAutoCloseStream(stream().unordered()); 251 | } 252 | 253 | @Override 254 | public Stream onClose(Runnable closeHandler) { 255 | return asAutoCloseStream(stream().onClose(closeHandler)); 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /src/main/java/com/apptasticsoftware/rssreader/module/itunes/ItunesOwner.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2022, Apptastic Software 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | package com.apptasticsoftware.rssreader.module.itunes; 25 | 26 | import java.util.Objects; 27 | import java.util.Optional; 28 | 29 | /** 30 | * Class representing the Itunes owner. 31 | */ 32 | public class ItunesOwner { 33 | private String name; 34 | private String email; 35 | 36 | 37 | /** 38 | * Get the name 39 | * @return name 40 | */ 41 | public Optional getName() { 42 | return Optional.ofNullable(name); 43 | } 44 | 45 | /** 46 | * Set the name 47 | * @param name name 48 | */ 49 | public void setName(String name) { 50 | this.name = name; 51 | } 52 | 53 | /** 54 | * Get the email 55 | * @return email 56 | */ 57 | public String getEmail() { 58 | return email; 59 | } 60 | 61 | /** 62 | * Set the email 63 | * @param email email 64 | */ 65 | public void setEmail(String email) { 66 | this.email = email; 67 | } 68 | 69 | @Override 70 | public boolean equals(Object o) { 71 | if (this == o) return true; 72 | if (o == null || getClass() != o.getClass()) return false; 73 | ItunesOwner that = (ItunesOwner) o; 74 | return Objects.equals(getName(), that.getName()) && Objects.equals(getEmail(), that.getEmail()); 75 | } 76 | 77 | @Override 78 | public int hashCode() { 79 | return Objects.hash(getName(), getEmail()); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/main/java/com/apptasticsoftware/rssreader/module/itunes/ItunesRssReader.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2022, Apptastic Software 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | package com.apptasticsoftware.rssreader.module.itunes; 25 | 26 | import com.apptasticsoftware.rssreader.AbstractRssReader; 27 | import com.apptasticsoftware.rssreader.DateTimeParser; 28 | 29 | import java.net.http.HttpClient; 30 | 31 | import static com.apptasticsoftware.rssreader.util.Mapper.mapBoolean; 32 | import static com.apptasticsoftware.rssreader.util.Mapper.mapInteger; 33 | 34 | /** 35 | * Class for reading podcast (itunes) feeds. 36 | */ 37 | public class ItunesRssReader extends AbstractRssReader { 38 | 39 | /** 40 | * Constructor 41 | */ 42 | public ItunesRssReader() { 43 | super(); 44 | } 45 | 46 | /** 47 | * Constructor 48 | * @param httpClient http client 49 | */ 50 | public ItunesRssReader(HttpClient httpClient) { 51 | super(httpClient); 52 | } 53 | 54 | @Override 55 | protected void registerChannelTags() { 56 | super.registerChannelTags(); 57 | addChannelExtension("itunes:explicit", (i, v) -> mapBoolean(v, i::setItunesExplicit)); 58 | addChannelExtension("itunes:author", ItunesChannel::setItunesAuthor); 59 | 60 | addChannelExtension("itunes:name", (i, v) -> { 61 | if (i.getItunesOwner().isEmpty()) 62 | i.setItunesOwner(new ItunesOwner()); 63 | i.getItunesOwner().ifPresent(a -> a.setName(v)); 64 | }); 65 | 66 | addChannelExtension("itunes:email", (i, v) -> { 67 | if (i.getItunesOwner().isEmpty()) 68 | i.setItunesOwner(new ItunesOwner()); 69 | i.getItunesOwner().ifPresent(a -> a.setEmail(v)); 70 | }); 71 | 72 | addChannelExtension("itunes:title", ItunesChannel::setItunesTitle); 73 | addChannelExtension("itunes:subtitle", ItunesChannel::setItunesSubtitle); 74 | addChannelExtension("itunes:summary", ItunesChannel::setItunesSummary); 75 | addChannelExtension("itunes:type", ItunesChannel::setItunesType); 76 | addChannelExtension("itunes:new-feed-url", ItunesChannel::setItunesNewFeedUrl); 77 | addChannelExtension("itunes:block", (i, v) -> mapBoolean(v, i::setItunesBlock)); 78 | addChannelExtension("itunes:complete", (i, v) -> mapBoolean(v, i::setItunesComplete)); 79 | } 80 | 81 | @Override 82 | protected void registerChannelAttributes() { 83 | super.registerChannelAttributes(); 84 | addChannelExtension("itunes:image", "href", ItunesChannel::setItunesImage); 85 | addChannelExtension("itunes:category", "text", ItunesChannel::addItunesCategory); 86 | } 87 | 88 | @Override 89 | protected void registerItemTags() { 90 | super.registerItemTags(); 91 | addItemExtension("itunes:duration", ItunesItem::setItunesDuration); 92 | addItemExtension("itunes:explicit", (i, v) -> mapBoolean(v, i::setItunesExplicit)); 93 | addItemExtension("itunes:title", ItunesItem::setItunesTitle); 94 | addItemExtension("itunes:subtitle", ItunesItem::setItunesSubtitle); 95 | addItemExtension("itunes:summary", ItunesItem::setItunesSummary); 96 | addItemExtension("itunes:keywords", ItunesItem::setItunesKeywords); 97 | addItemExtension("itunes:episode", (i, v) -> mapInteger(v, i::setItunesEpisode)); 98 | addItemExtension("itunes:season", (i, v) -> mapInteger(v, i::setItunesSeason)); 99 | addItemExtension("itunes:episodeType", ItunesItem::setItunesEpisodeType); 100 | addItemExtension("itunes:block", (i, v) -> mapBoolean(v, i::setItunesBlock)); 101 | addItemExtension("itunes:image", "href", ItunesItem::setItunesImage); 102 | } 103 | 104 | @Override 105 | protected ItunesChannel createChannel(DateTimeParser dateTimeParser) { 106 | return new ItunesChannel(dateTimeParser); 107 | } 108 | 109 | @Override 110 | protected ItunesItem createItem(DateTimeParser dateTimeParser) { 111 | return new ItunesItem(dateTimeParser); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/main/java/com/apptasticsoftware/rssreader/module/mediarss/MediaRssItem.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2022, Apptastic Software 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | package com.apptasticsoftware.rssreader.module.mediarss; 25 | 26 | import com.apptasticsoftware.rssreader.DateTimeParser; 27 | import com.apptasticsoftware.rssreader.Item; 28 | 29 | import java.util.Objects; 30 | import java.util.Optional; 31 | 32 | /** 33 | * Class representing the media rss item. 34 | */ 35 | public class MediaRssItem extends Item { 36 | private MediaThumbnail mediaThumbnail; 37 | 38 | /** 39 | * Constructor 40 | * 41 | * @param dateTimeParser timestamp parser 42 | */ 43 | public MediaRssItem(DateTimeParser dateTimeParser) { 44 | super(dateTimeParser); 45 | } 46 | 47 | /** 48 | * Get the media thumbnail 49 | * 50 | * @return media thumbnail 51 | */ 52 | public Optional getMediaThumbnail() { 53 | return Optional.ofNullable(mediaThumbnail); 54 | } 55 | 56 | /** 57 | * Set the media thumbnail 58 | * 59 | * @param mediaThumbnail media thumbnail 60 | */ 61 | public void setMediaThumbnail(MediaThumbnail mediaThumbnail) { 62 | this.mediaThumbnail = mediaThumbnail; 63 | } 64 | 65 | @Override 66 | public boolean equals(Object o) { 67 | if (this == o) return true; 68 | if (o == null || getClass() != o.getClass()) return false; 69 | if (!super.equals(o)) return false; 70 | MediaRssItem that = (MediaRssItem) o; 71 | return Objects.equals(getMediaThumbnail(), that.getMediaThumbnail()); 72 | } 73 | 74 | @Override 75 | public int hashCode() { 76 | return Objects.hash(super.hashCode(), getMediaThumbnail()); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/main/java/com/apptasticsoftware/rssreader/module/mediarss/MediaRssReader.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2022, Apptastic Software 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | package com.apptasticsoftware.rssreader.module.mediarss; 25 | 26 | import com.apptasticsoftware.rssreader.AbstractRssReader; 27 | import com.apptasticsoftware.rssreader.Channel; 28 | import com.apptasticsoftware.rssreader.DateTimeParser; 29 | 30 | import java.net.http.HttpClient; 31 | import java.util.function.BiConsumer; 32 | 33 | /** 34 | * Class for reading media rss feeds. 35 | */ 36 | public class MediaRssReader extends AbstractRssReader { 37 | 38 | /** 39 | * Constructor 40 | */ 41 | public MediaRssReader() { 42 | super(); 43 | } 44 | 45 | /** 46 | * Constructor 47 | * @param httpClient http client 48 | */ 49 | public MediaRssReader(HttpClient httpClient) { 50 | super(httpClient); 51 | } 52 | 53 | @Override 54 | protected Channel createChannel(DateTimeParser dateTimeParser) { 55 | return new Channel(dateTimeParser); 56 | } 57 | 58 | @Override 59 | protected MediaRssItem createItem(DateTimeParser dateTimeParser) { 60 | return new MediaRssItem(dateTimeParser); 61 | } 62 | 63 | @SuppressWarnings("java:S1192") 64 | @Override 65 | protected void registerItemAttributes() { 66 | super.registerItemAttributes(); 67 | super.addItemExtension("media:thumbnail", "url", mediaThumbnailSetterTemplateBuilder(MediaThumbnail::setUrl)); 68 | super.addItemExtension("media:thumbnail", "height", mediaThumbnailSetterTemplateBuilder( 69 | (mediaThumbnail, height) -> mediaThumbnail.setHeight(Integer.parseInt(height)) 70 | )); 71 | super.addItemExtension("media:thumbnail", "width", mediaThumbnailSetterTemplateBuilder( 72 | (mediaThumbnail, width) -> mediaThumbnail.setWidth(Integer.parseInt(width)) 73 | )); 74 | } 75 | 76 | private BiConsumer mediaThumbnailSetterTemplateBuilder(BiConsumer setter) { 77 | return (mediaRssItem, value) -> { 78 | var mediaThumbnail = mediaRssItem.getMediaThumbnail().orElse(new MediaThumbnail()); 79 | setter.accept(mediaThumbnail, value); 80 | mediaRssItem.setMediaThumbnail(mediaThumbnail); 81 | }; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/main/java/com/apptasticsoftware/rssreader/module/mediarss/MediaThumbnail.java: -------------------------------------------------------------------------------- 1 | package com.apptasticsoftware.rssreader.module.mediarss; 2 | 3 | import java.util.Optional; 4 | 5 | /** 6 | * Class representing the media thumbnail from the media rss spec. 7 | * See for details. 8 | */ 9 | public class MediaThumbnail { 10 | private String url; 11 | private Integer width; 12 | private Integer height; 13 | private String time; 14 | 15 | /** 16 | * Get the url of the thumbnail 17 | * 18 | * @return url 19 | */ 20 | public String getUrl() { 21 | return url; 22 | } 23 | 24 | /** 25 | * Set the url of the thumbnail 26 | * 27 | * @param url url 28 | */ 29 | public void setUrl(String url) { 30 | this.url = url; 31 | } 32 | 33 | /** 34 | * Get the width of the thumbnail 35 | * 36 | * @return width 37 | */ 38 | public Optional getWidth() { 39 | return Optional.ofNullable(width); 40 | } 41 | 42 | /** 43 | * Set the width of the thumbnail 44 | * 45 | * @param width width 46 | */ 47 | public void setWidth(Integer width) { 48 | this.width = width; 49 | } 50 | 51 | /** 52 | * Get the height of the thumbnail 53 | * 54 | * @return height 55 | */ 56 | public Optional getHeight() { 57 | return Optional.ofNullable(height); 58 | } 59 | 60 | /** 61 | * Set the height of the thumbnail 62 | * 63 | * @param height height 64 | */ 65 | public void setHeight(Integer height) { 66 | this.height = height; 67 | } 68 | 69 | /** 70 | * Get the time of the thumbnail 71 | * 72 | * @return time 73 | */ 74 | public Optional getTime() { 75 | return Optional.ofNullable(time); 76 | } 77 | 78 | /** 79 | * Set the time of the thumbnail 80 | * 81 | * @param time time 82 | */ 83 | public void setTime(String time) { 84 | this.time = time; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/main/java/com/apptasticsoftware/rssreader/package-info.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2022, Apptastic Software 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | /** 26 | * This package is intended for RSS reader. 27 | */ 28 | package com.apptasticsoftware.rssreader; -------------------------------------------------------------------------------- /src/main/java/com/apptasticsoftware/rssreader/util/Default.java: -------------------------------------------------------------------------------- 1 | package com.apptasticsoftware.rssreader.util; 2 | 3 | import com.apptasticsoftware.rssreader.DateTime; 4 | import com.apptasticsoftware.rssreader.DateTimeParser; 5 | 6 | /** 7 | * Provides default implementations for various components. 8 | */ 9 | @SuppressWarnings("javaarchitecture:S7091") 10 | public class Default { 11 | 12 | private Default() { 13 | // Utility class 14 | } 15 | 16 | /** 17 | * Get the default date time parser. 18 | * @return date time parser 19 | */ 20 | public static DateTimeParser getDateTimeParser() { 21 | return new DateTime(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/apptasticsoftware/rssreader/util/ItemComparator.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2022, Apptastic Software 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | package com.apptasticsoftware.rssreader.util; 25 | 26 | import com.apptasticsoftware.rssreader.Channel; 27 | import com.apptasticsoftware.rssreader.DateTimeParser; 28 | import com.apptasticsoftware.rssreader.Item; 29 | 30 | import java.util.Comparator; 31 | import java.util.Objects; 32 | 33 | /** 34 | * Provides different comparators for sorting item objects. 35 | */ 36 | @SuppressWarnings("java:S1133") 37 | public final class ItemComparator { 38 | private static final String MUST_NOT_BE_NULL_MESSAGE = "Date time parser must not be null"; 39 | 40 | private ItemComparator() { 41 | 42 | } 43 | 44 | /** 45 | * Comparator for sorting Items on initial creation or first availability (publication date) in ascending order (oldest first) 46 | * @param any class that extends Item 47 | * @return comparator 48 | * 49 | * @deprecated As of release 3.9.0, replaced by {@link #oldestPublishedItemFirst()} 50 | */ 51 | @Deprecated(since = "3.9.0", forRemoval = true) 52 | public static Comparator oldestItemFirst() { 53 | return oldestPublishedItemFirst(); 54 | } 55 | 56 | /** 57 | * Comparator for sorting Items on initial creation or first availability (publication date) in ascending order (oldest first) 58 | * @param any class that extends Item 59 | * @return comparator 60 | */ 61 | public static Comparator oldestPublishedItemFirst() { 62 | return Comparator.comparing((I i) -> 63 | i.getPubDateZonedDateTime().orElse(null), 64 | Comparator.nullsLast(Comparator.naturalOrder())); 65 | } 66 | 67 | /** 68 | * Comparator for sorting Items on updated date if exist otherwise on publication date in ascending order (oldest first) 69 | * @param any class that extends Item 70 | * @return comparator 71 | */ 72 | public static Comparator oldestUpdatedItemFirst() { 73 | return Comparator.comparing((I i) -> 74 | i.getUpdatedZonedDateTime().orElse(i.getPubDateZonedDateTime().orElse(null)), 75 | Comparator.nullsLast(Comparator.naturalOrder())); 76 | } 77 | 78 | /** 79 | * Comparator for sorting Items on initial creation or first availability (publication date) in ascending order (oldest first) 80 | * @param any class that extends Item 81 | * @param dateTimeParser date time parser 82 | * @return comparator 83 | * 84 | * @deprecated As of release 3.9.0, replaced by {@link #oldestPublishedItemFirst(DateTimeParser)} 85 | */ 86 | @Deprecated(since = "3.9.0", forRemoval = true) 87 | public static Comparator oldestItemFirst(DateTimeParser dateTimeParser) { 88 | return oldestPublishedItemFirst(dateTimeParser); 89 | } 90 | 91 | /** 92 | * Comparator for sorting Items on initial creation or first availability (publication date) in ascending order (oldest first) 93 | * @param any class that extends Item 94 | * @param dateTimeParser date time parser 95 | * @return comparator 96 | */ 97 | public static Comparator oldestPublishedItemFirst(DateTimeParser dateTimeParser) { 98 | Objects.requireNonNull(dateTimeParser, MUST_NOT_BE_NULL_MESSAGE); 99 | return Comparator.comparing((I i) -> 100 | i.getPubDate().map(dateTimeParser::parse).orElse(null), 101 | Comparator.nullsLast(Comparator.naturalOrder())); 102 | } 103 | 104 | /** 105 | * Comparator for sorting Items on updated date if exist otherwise on publication date in ascending order (oldest first) 106 | * @param any class that extends Item 107 | * @param dateTimeParser date time parser 108 | * @return comparator 109 | */ 110 | public static Comparator oldestUpdatedItemFirst(DateTimeParser dateTimeParser) { 111 | Objects.requireNonNull(dateTimeParser, MUST_NOT_BE_NULL_MESSAGE); 112 | return Comparator.comparing((I i) -> 113 | i.getUpdated().or(i::getPubDate).map(dateTimeParser::parse).orElse(null), 114 | Comparator.nullsLast(Comparator.naturalOrder())); 115 | } 116 | 117 | /** 118 | * Comparator for sorting Items on initial creation or first availability (publication date) in descending order (newest first) 119 | * @param any class that extends Item 120 | * @return comparator 121 | * 122 | * @deprecated As of release 3.9.0, replaced by {@link #newestPublishedItemFirst()} 123 | */ 124 | @Deprecated(since = "3.9.0", forRemoval = true) 125 | public static Comparator newestItemFirst() { 126 | return newestPublishedItemFirst(); 127 | } 128 | 129 | /** 130 | * Comparator for sorting Items on initial creation or first availability (publication date) in descending order (newest first) 131 | * @param any class that extends Item 132 | * @return comparator 133 | */ 134 | public static Comparator newestPublishedItemFirst() { 135 | return Comparator.comparing((I i) -> 136 | i.getPubDateZonedDateTime().orElse(null), 137 | Comparator.nullsLast(Comparator.naturalOrder())).reversed(); 138 | } 139 | 140 | /** 141 | * Comparator for sorting Items on updated date if exist otherwise on publication date in descending order (newest first) 142 | * @param any class that extends Item 143 | * @return comparator 144 | */ 145 | public static Comparator newestUpdatedItemFirst() { 146 | return Comparator.comparing((I i) -> 147 | i.getUpdatedZonedDateTime().orElse(i.getPubDateZonedDateTime().orElse(null)), 148 | Comparator.nullsLast(Comparator.naturalOrder())).reversed(); 149 | } 150 | 151 | /** 152 | * Comparator for sorting Items on initial creation or first availability (publication date) in descending order (newest first) 153 | * @param any class that extends Item 154 | * @param dateTimeParser date time parser 155 | * @return comparator 156 | * 157 | * @deprecated As of release 3.9.0, replaced by {@link #newestPublishedItemFirst(DateTimeParser)} 158 | */ 159 | @Deprecated(since = "3.9.0", forRemoval = true) 160 | public static Comparator newestItemFirst(DateTimeParser dateTimeParser) { 161 | return newestPublishedItemFirst(dateTimeParser); 162 | } 163 | 164 | /** 165 | * Comparator for sorting Items on initial creation or first availability (publication date) in descending order (newest first) 166 | * @param any class that extends Item 167 | * @param dateTimeParser date time parser 168 | * @return comparator 169 | */ 170 | public static Comparator newestPublishedItemFirst(DateTimeParser dateTimeParser) { 171 | Objects.requireNonNull(dateTimeParser, MUST_NOT_BE_NULL_MESSAGE); 172 | return Comparator.comparing((I i) -> 173 | i.getPubDate().map(dateTimeParser::parse).orElse(null), 174 | Comparator.nullsLast(Comparator.naturalOrder())).reversed(); 175 | } 176 | 177 | /** 178 | * Comparator for sorting Items on updated date if exist otherwise on publication date in descending order (newest first) 179 | * @param any class that extends Item 180 | * @param dateTimeParser date time parser 181 | * @return comparator 182 | */ 183 | public static Comparator newestUpdatedItemFirst(DateTimeParser dateTimeParser) { 184 | Objects.requireNonNull(dateTimeParser, MUST_NOT_BE_NULL_MESSAGE); 185 | return Comparator.comparing((I i) -> 186 | i.getUpdated().or(i::getPubDate).map(dateTimeParser::parse).orElse(null), 187 | Comparator.nullsLast(Comparator.naturalOrder())).reversed(); 188 | } 189 | 190 | /** 191 | * Comparator for sorting Items on channel title 192 | * @param any class that extends Item 193 | * @return comparator 194 | */ 195 | public static Comparator channelTitle() { 196 | return Comparator.comparing( 197 | Item::getChannel, 198 | Comparator.nullsFirst(Comparator.comparing( 199 | Channel::getTitle, Comparator.nullsFirst(Comparator.naturalOrder())))); 200 | } 201 | 202 | } -------------------------------------------------------------------------------- /src/main/java/com/apptasticsoftware/rssreader/util/Mapper.java: -------------------------------------------------------------------------------- 1 | package com.apptasticsoftware.rssreader.util; 2 | 3 | import java.util.Optional; 4 | import java.util.function.Consumer; 5 | import java.util.function.Function; 6 | import java.util.function.Supplier; 7 | import java.util.logging.Level; 8 | import java.util.logging.Logger; 9 | 10 | /** 11 | * Provides methods for mapping field 12 | */ 13 | public final class Mapper { 14 | private static final Logger LOGGER = Logger.getLogger("com.apptasticsoftware.rssreader.util"); 15 | 16 | private Mapper() { } 17 | 18 | /** 19 | * Maps a boolean text value (true, false, no or yes) to a boolean field. Text value can be in any casing. 20 | * @param text text value 21 | * @param func boolean setter method 22 | */ 23 | public static void mapBoolean(String text, Consumer func) { 24 | text = text.toLowerCase(); 25 | if ("true".equals(text) || "yes".equals(text)) { 26 | func.accept(Boolean.TRUE); 27 | } else if ("false".equals(text) || "no".equals(text)) { 28 | func.accept(Boolean.FALSE); 29 | } 30 | } 31 | 32 | /** 33 | * Maps a integer text value to a integer field. 34 | * @param text text value 35 | * @param func integer setter method 36 | */ 37 | public static void mapInteger(String text, Consumer func) { 38 | mapNumber(text, func, Integer::valueOf); 39 | } 40 | 41 | /** 42 | * Maps a long text value to a long field. 43 | * @param text text value 44 | * @param func long setter method 45 | */ 46 | public static void mapLong(String text, Consumer func) { 47 | mapNumber(text, func, Long::valueOf); 48 | } 49 | 50 | private static void mapNumber(String text, Consumer func, Function convert) { 51 | if (!isNullOrEmpty(text)) { 52 | try { 53 | func.accept(convert.apply(text)); 54 | } catch (NumberFormatException e) { 55 | if (LOGGER.isLoggable(Level.WARNING)) { 56 | LOGGER.log(Level.WARNING, () -> String.format("Failed to convert %s. Message: %s", text, e.getMessage())); 57 | } 58 | } 59 | } 60 | } 61 | 62 | /** 63 | * Map value if field has not been mapped before 64 | * @param text value to map 65 | * @param getter getter to check if field is empty 66 | * @param setter setter to set value 67 | * @param type 68 | */ 69 | public static void mapIfEmpty(String text, Supplier getter, Consumer setter) { 70 | if (isNullOrEmpty(getter) && !isNullOrEmpty(text)) { 71 | setter.accept(text); 72 | } 73 | } 74 | 75 | /** 76 | * Create a new instance if a getter returns optional empty and assigns the field the new instance. 77 | * @param getter getter method 78 | * @param setter setter method 79 | * @param factory factory for creating a new instance if field is not set before 80 | * @return existing or new instance 81 | * @param any class 82 | */ 83 | public static T createIfNull(Supplier> getter, Consumer setter, Supplier factory) { 84 | return createIfNullOptional(getter, setter, factory).orElse(null); 85 | } 86 | 87 | /** 88 | * Create a new instance if a getter returns optional empty and assigns the field the new instance. 89 | * @param getter getter method 90 | * @param setter setter method 91 | * @param factory factory for creating a new instance if field is not set before 92 | * @return existing or new instance 93 | * @param any class 94 | */ 95 | public static Optional createIfNullOptional(Supplier> getter, Consumer setter, Supplier factory) { 96 | Optional instance = getter.get(); 97 | if (instance.isEmpty()) { 98 | T newInstance = factory.get(); 99 | setter.accept(newInstance); 100 | instance = Optional.of(newInstance); 101 | } 102 | return instance; 103 | } 104 | 105 | private static boolean isNullOrEmpty(Supplier getter) { 106 | return getter.get() == null || 107 | "".equals(getter.get()) || 108 | getter.get() == Optional.empty() || 109 | getter.get() instanceof Optional && 110 | ((Optional) getter.get()) 111 | .filter(String.class::isInstance) 112 | .map(String.class::cast) 113 | .map(String::isBlank) 114 | .orElse(false); 115 | } 116 | 117 | private static boolean isNullOrEmpty(String text) { 118 | return text == null || text.isBlank(); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/main/java/com/apptasticsoftware/rssreader/util/Util.java: -------------------------------------------------------------------------------- 1 | package com.apptasticsoftware.rssreader.util; 2 | 3 | import java.util.Locale; 4 | 5 | /** 6 | * Utility class for RSS reader. 7 | */ 8 | public class Util { 9 | 10 | private Util() { 11 | 12 | } 13 | 14 | /** 15 | * Convert a time period string to hours. 16 | * 17 | * @param period the time period string (e.g., "daily", "weekly", "monthly", "yearly", "hourly") 18 | * @return the number of hours in the given time period, or 1 if the period is not recognized 19 | */ 20 | public static int toMinutes(String period) { 21 | switch (period.toLowerCase(Locale.ENGLISH)) { 22 | case "daily": return 1440; 23 | case "weekly": return 10080; 24 | case "monthly": return 43800; 25 | case "yearly": return 525600; 26 | case "hourly": 27 | default: return 60; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/module-info.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2022, Apptastic Software 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | /** 26 | * These modules define the base APIs for RSS reader. 27 | */ 28 | module com.apptasticsoftware.rssreader { 29 | requires java.net.http; 30 | requires java.xml; 31 | requires java.logging; 32 | 33 | exports com.apptasticsoftware.rssreader; 34 | exports com.apptasticsoftware.rssreader.util; 35 | exports com.apptasticsoftware.rssreader.module.itunes; 36 | exports com.apptasticsoftware.rssreader.module.mediarss; 37 | } -------------------------------------------------------------------------------- /src/test/java/com/apptasticsoftware/integrationtest/ConnectionTest.java: -------------------------------------------------------------------------------- 1 | package com.apptasticsoftware.integrationtest; 2 | 3 | import com.apptasticsoftware.rssreader.Item; 4 | import com.apptasticsoftware.rssreader.RssReader; 5 | import com.apptasticsoftware.rssreader.internal.RssServer; 6 | import org.junit.jupiter.api.Test; 7 | 8 | import java.io.File; 9 | import java.io.IOException; 10 | import java.time.Duration; 11 | import java.time.ZonedDateTime; 12 | import java.util.List; 13 | import java.util.stream.Collectors; 14 | 15 | import static org.junit.jupiter.api.Assertions.*; 16 | 17 | class ConnectionTest { 18 | private static final int PORT = 8008; 19 | private static final Duration NEGATIVE_DURATION = Duration.ofSeconds(-30); 20 | 21 | @Test 22 | void testConnectionTimeoutWithNullValue() { 23 | var rssReader = new RssReader(); 24 | var exception = assertThrows(NullPointerException.class, () -> rssReader.setConnectionTimeout(null)); 25 | assertEquals("Connection timeout must not be null", exception.getMessage()); 26 | } 27 | 28 | @Test 29 | void testRequestTimeoutWithNullValue() { 30 | var rssReader = new RssReader(); 31 | var exception = assertThrows(NullPointerException.class, () -> rssReader.setRequestTimeout(null)); 32 | assertEquals("Request timeout must not be null", exception.getMessage()); 33 | } 34 | 35 | @Test 36 | void testReadTimeoutWithNullValue() { 37 | var rssReader = new RssReader(); 38 | var exception = assertThrows(NullPointerException.class, () -> rssReader.setReadTimeout(null)); 39 | assertEquals("Read timeout must not be null", exception.getMessage()); 40 | } 41 | 42 | @Test 43 | void testConnectionTimeoutWithNegativeValue() { 44 | var rssReader = new RssReader(); 45 | var exception = assertThrows(IllegalArgumentException.class, () -> rssReader.setConnectionTimeout(NEGATIVE_DURATION)); 46 | assertEquals("Connection timeout must not be negative", exception.getMessage()); 47 | } 48 | 49 | @Test 50 | void testRequestTimeoutWithNegativeValue() { 51 | var rssReader = new RssReader(); 52 | var exception = assertThrows(IllegalArgumentException.class, () -> rssReader.setRequestTimeout(NEGATIVE_DURATION)); 53 | assertEquals("Request timeout must not be negative", exception.getMessage()); 54 | } 55 | 56 | @Test 57 | void testReadTimeoutWithNegativeValue() { 58 | var rssReader = new RssReader(); 59 | var exception = assertThrows(IllegalArgumentException.class, () -> rssReader.setReadTimeout(NEGATIVE_DURATION)); 60 | assertEquals("Read timeout must not be negative", exception.getMessage()); 61 | } 62 | 63 | @Test 64 | void testReadFromLocalRssServerNoTimeout() throws IOException { 65 | var server = RssServer.with(getFile("atom-feed.xml")) 66 | .port(PORT) 67 | .endpointPath("/rss") 68 | .build(); 69 | server.start(); 70 | 71 | var items = new RssReader() 72 | .setConnectionTimeout(Duration.ZERO) 73 | .setRequestTimeout(Duration.ZERO) 74 | .setReadTimeout(Duration.ZERO) 75 | .read("http://localhost:8008/rss") 76 | .collect(Collectors.toList()); 77 | 78 | server.stop(); 79 | verify(3, items); 80 | } 81 | 82 | @Test 83 | void testReadFromLocalRssServer10SecondTimeout() throws IOException { 84 | var server = RssServer.with(getFile("atom-feed.xml")) 85 | .port(PORT) 86 | .endpointPath("/rss") 87 | .build(); 88 | server.start(); 89 | 90 | var items = new RssReader() 91 | .setConnectionTimeout(Duration.ofSeconds(10)) 92 | .setRequestTimeout(Duration.ofSeconds(10)) 93 | .setReadTimeout(Duration.ofSeconds(10)) 94 | .read("http://localhost:8008/rss") 95 | .collect(Collectors.toList()); 96 | 97 | server.stop(); 98 | verify(3, items); 99 | } 100 | 101 | 102 | @Test 103 | void testReadFromLocalRssServer() throws IOException { 104 | var server = RssServer.with(getFile("atom-feed.xml")) 105 | .port(PORT) 106 | .endpointPath("/rss") 107 | .build(); 108 | server.start(); 109 | 110 | var items = new RssReader() 111 | .setReadTimeout(Duration.ofSeconds(2)) 112 | .read("http://localhost:8008/rss") 113 | .collect(Collectors.toList()); 114 | 115 | server.stop(); 116 | verify(3, items); 117 | } 118 | 119 | @Test 120 | void testNoReadTimeout() throws IOException { 121 | var server = RssServer.with(getFile("atom-feed.xml")) 122 | .port(PORT) 123 | .endpointPath("/rss") 124 | .build(); 125 | server.start(); 126 | 127 | var items = new RssReader() 128 | .setReadTimeout(Duration.ZERO) 129 | .read("http://localhost:8008/rss") 130 | .collect(Collectors.toList()); 131 | 132 | server.stop(); 133 | verify(3, items); 134 | } 135 | 136 | @Test 137 | void testReadTimeout() throws IOException { 138 | var server = RssServer.withWritePause(getFile("atom-feed.xml"), Duration.ofSeconds(4)) 139 | .port(PORT) 140 | .endpointPath("/slow-server") 141 | .build(); 142 | server.start(); 143 | 144 | var items = new RssReader() 145 | .setReadTimeout(Duration.ofSeconds(2)) 146 | .read("http://localhost:8008/slow-server") 147 | .collect(Collectors.toList()); 148 | 149 | server.stop(); 150 | verify(2, items); 151 | } 152 | 153 | private static void verify(int expectedSize, List items) { 154 | assertEquals(expectedSize, items.size()); 155 | 156 | if (!items.isEmpty()) { 157 | assertEquals("dive into mark", items.get(0).getChannel().getTitle()); 158 | assertEquals(65, items.get(0).getChannel().getDescription().length()); 159 | assertEquals("http://example.org/feed.atom", items.get(0).getChannel().getLink()); 160 | assertEquals("Copyright (c) 2003, Mark Pilgrim", items.get(0).getChannel().getCopyright().orElse(null)); 161 | assertEquals("Example Toolkit", items.get(0).getChannel().getGenerator().orElse(null)); 162 | assertEquals("2005-07-31T12:29:29Z", items.get(0).getChannel().getLastBuildDate().orElse(null)); 163 | 164 | assertEquals("Atom draft-07 snapshot", items.get(0).getTitle().orElse(null)); 165 | assertNull(items.get(1).getAuthor().orElse(null)); 166 | assertEquals("http://example.org/audio/ph34r_my_podcast.mp3", items.get(0).getLink().orElse(null)); 167 | assertEquals("tag:example.org,2003:3.2397", items.get(0).getGuid().orElse(null)); 168 | assertEquals("2003-12-13T08:29:29-04:00", items.get(0).getPubDate().orElse(null)); 169 | assertEquals("2005-07-31T12:29:29Z", items.get(0).getUpdated().orElse(null)); 170 | assertEquals(211, items.get(1).getDescription().orElse("").length()); 171 | } 172 | if (items.size() >= 2) { 173 | assertEquals("Atom-Powered Robots Run Amok", items.get(1).getTitle().orElse(null)); 174 | assertNull(items.get(1).getAuthor().orElse(null)); 175 | assertEquals("http://example.org/2003/12/13/atom03", items.get(1).getLink().orElse(null)); 176 | assertEquals("urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a", items.get(1).getGuid().orElse(null)); 177 | assertEquals("2003-12-13T18:30:02Z", items.get(1).getPubDate().orElse(null)); 178 | assertEquals("2003-12-13T18:30:02Z", items.get(1).getUpdated().orElse(null)); 179 | assertEquals(211, items.get(1).getDescription().orElse("").length()); 180 | } 181 | if (items.size() >= 3) { 182 | assertEquals("Atom-Powered Robots Run Amok 2", items.get(2).getTitle().orElse(null)); 183 | assertNull(items.get(2).getAuthor().orElse(null)); 184 | assertEquals("http://example.org/2003/12/13/atom04", items.get(2).getLink().orElse(null)); 185 | assertEquals("urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6b", items.get(2).getGuid().orElse(null)); 186 | assertEquals("2003-12-13T09:28:28-04:00", items.get(2).getPubDate().orElse(null)); 187 | assertEquals(1071322108, items.get(2).getPubDateZonedDateTime().map(ZonedDateTime::toEpochSecond).orElse(null)); 188 | assertEquals("2003-12-13T18:30:01Z", items.get(2).getUpdated().orElse(null)); 189 | assertEquals(1071340201, items.get(2).getUpdatedZonedDateTime().map(ZonedDateTime::toEpochSecond).orElse(null)); 190 | assertEquals(47, items.get(2).getDescription().orElse("").length()); 191 | } 192 | } 193 | 194 | private File getFile(String filename) { 195 | var url = getClass().getClassLoader().getResource(filename); 196 | return new File(url.getFile()); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/test/java/com/apptasticsoftware/integrationtest/SortTest.java: -------------------------------------------------------------------------------- 1 | package com.apptasticsoftware.integrationtest; 2 | 3 | import com.apptasticsoftware.rssreader.Item; 4 | import com.apptasticsoftware.rssreader.RssReader; 5 | import com.apptasticsoftware.rssreader.util.ItemComparator; 6 | import org.junit.jupiter.api.Test; 7 | 8 | import java.io.IOException; 9 | import java.util.List; 10 | import java.util.Optional; 11 | import java.util.stream.Collectors; 12 | 13 | import static org.junit.jupiter.api.Assertions.assertNotEquals; 14 | import static org.junit.jupiter.api.Assertions.assertTrue; 15 | import static org.junit.jupiter.api.Assertions.assertFalse; 16 | 17 | class SortTest { 18 | 19 | @Test 20 | void testTimestampSortTest() { 21 | var urlList = List.of( 22 | "https://www.riksbank.se/sv/rss/pressmeddelanden", 23 | "https://www.konj.se/4.2de5c57614f808a95afcc13f/12.2de5c57614f808a95afcc354.portlet?state=rss&sv.contenttype=text/xml;charset=UTF-8", 24 | "https://www.scb.se/Feed/statistiknyheter/", 25 | "https://www.avanza.se/placera/forstasidan.rss.xml", 26 | "https://www.breakit.se/feed/artiklar", 27 | "https://feedforall.com/sample-feed.xml", 28 | "https://se.investing.com/rss/news.rss", 29 | "https://www.di.se/digital/rss", 30 | "https://worldoftanks.eu/en/rss/news/", 31 | "https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml", 32 | "https://github.com/openjdk/jdk/commits.atom", 33 | "https://www.microsoft.com/releasecommunications/api/v2/azure/rss", 34 | "https://blog.ploeh.dk/rss.xml", 35 | "https://www.politico.com/rss/politicopicks.xml", 36 | "https://www.e1.ru/talk/forum/rss.php?f=86", 37 | "https://failed-to-read-from-this-url.com", 38 | "https://www.nrdc.org/rss.xml", 39 | "https://www.theverge.com/rss/reviews/index.xml", 40 | "https://feeds.macrumors.com/MacRumors-All", 41 | "https://www.ksl.com/rss/news", 42 | "http://rss.cnn.com/rss/cnn_latest.rss", 43 | "https://moxie.foxnews.com/google-publisher/latest.xml", 44 | "https://techcrunch.com/feed/", 45 | "https://feeds.arstechnica.com/arstechnica/science" 46 | ); 47 | 48 | var timestamps = new RssReader().read(urlList) 49 | .sorted() 50 | .map(Item::getPubDateZonedDateTime) 51 | .flatMap(Optional::stream) 52 | .map(t -> t.toInstant().toEpochMilli()) 53 | .collect(Collectors.toList()); 54 | 55 | assertTrue(timestamps.size() > 200); 56 | 57 | var iterator = timestamps.iterator(); 58 | Long current, previous = iterator.next(); 59 | while (iterator.hasNext()) { 60 | current = iterator.next(); 61 | assertTrue(previous.compareTo(current) >= 0); 62 | previous = current; 63 | } 64 | } 65 | 66 | @Test 67 | void testSortNewestFirst() throws IOException { 68 | var list = new RssReader().read("https://feeds.macrumors.com/MacRumors-All") 69 | .sorted(ItemComparator.newestPublishedItemFirst()) 70 | .collect(Collectors.toList()); 71 | 72 | assertFalse(list.isEmpty()); 73 | 74 | var previous = list.get(0); 75 | for (Item current : list) { 76 | assertTrue(previous.compareTo(current) <= 0); 77 | previous = current; 78 | } 79 | } 80 | 81 | @Test 82 | void testSortOldestFirst() throws IOException { 83 | var list = new RssReader().read("https://feeds.macrumors.com/MacRumors-All") 84 | .sorted(ItemComparator.oldestPublishedItemFirst()) 85 | .collect(Collectors.toList()); 86 | 87 | assertFalse(list.isEmpty()); 88 | 89 | var previous = list.get(0); 90 | for (Item current : list) { 91 | assertTrue(previous.compareTo(current) >= 0); 92 | previous = current; 93 | } 94 | } 95 | 96 | @Test 97 | void testSortChannelTitle() { 98 | var urls = List.of("https://feeds.a.dj.com/rss/RSSMarketsMain.xml", "https://gizmodo.com/feed"); 99 | var list = new RssReader().read(urls) 100 | .sorted(ItemComparator.channelTitle()) 101 | .collect(Collectors.toList()); 102 | 103 | var first = list.get(0); 104 | var last = list.get(list.size() - 1); 105 | assertNotEquals(first.getChannel().getTitle(), last.getChannel().getTitle()); 106 | assertTrue(first.getChannel().getTitle().toLowerCase().contains("gizmodo")); 107 | assertTrue(last.getChannel().getTitle().toLowerCase().contains("wsj")); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/test/java/com/apptasticsoftware/rssreader/internal/RssServer.java: -------------------------------------------------------------------------------- 1 | package com.apptasticsoftware.rssreader.internal; 2 | 3 | import com.sun.net.httpserver.HttpExchange; 4 | import com.sun.net.httpserver.HttpHandler; 5 | import com.sun.net.httpserver.HttpServer; 6 | 7 | import java.io.*; 8 | import java.net.InetSocketAddress; 9 | import java.nio.file.Files; 10 | import java.time.Duration; 11 | import java.time.Instant; 12 | import java.util.Objects; 13 | import java.util.concurrent.TimeUnit; 14 | import java.util.logging.Logger; 15 | 16 | /** 17 | * Basic RSS server from testing 18 | */ 19 | public class RssServer { 20 | private static final Logger LOGGER = Logger.getLogger("RssServer"); 21 | private final HttpServer server; 22 | 23 | private RssServer(int port, String endpointPath, File file, Duration writeBodyPause) throws IOException { 24 | server = HttpServer.create(new InetSocketAddress(port), 0); 25 | server.createContext(endpointPath, new FileRssHandler(file, writeBodyPause)); 26 | server.setExecutor(null); 27 | } 28 | 29 | /** 30 | * RSS server that publish the given file content as an RSS/Atom feed. 31 | * @param file content to publish 32 | * @return RSS server 33 | */ 34 | public static RssServerBuilder with(File file) { 35 | Objects.requireNonNull(file, "File must not be null"); 36 | if (!file.isFile()) { 37 | throw new IllegalArgumentException("File must exist"); 38 | } 39 | return new RssServerBuilder(file, Duration.ZERO); 40 | } 41 | 42 | /** 43 | * RSS server that publish the given file content as an RSS/Atom feed. 44 | * Server will publish 90% of the data and then wait the given amount of time before publish the rest of the data. 45 | * @param file content to publish 46 | * @param writeBodyPause time to wait before publishing the last data 47 | * @return RSS server 48 | */ 49 | public static RssServerBuilder withWritePause(File file, Duration writeBodyPause) { 50 | Objects.requireNonNull(file, "File must not be null"); 51 | if (!file.isFile()) { 52 | throw new IllegalArgumentException("File must exist"); 53 | } 54 | Objects.requireNonNull(writeBodyPause, "Write body pause must not be null"); 55 | if (writeBodyPause.isNegative()) { 56 | throw new IllegalArgumentException("Write body pause must not be negative"); 57 | } 58 | return new RssServerBuilder(file, writeBodyPause); 59 | } 60 | 61 | /** 62 | * Start RSS server 63 | */ 64 | public void start() { 65 | server.start(); 66 | } 67 | 68 | /** 69 | * Stop RSS server 70 | */ 71 | public void stop() { 72 | server.stop(1); 73 | } 74 | 75 | private static class FileRssHandler implements HttpHandler { 76 | private final File file; 77 | private final Duration writeBodyPause; 78 | 79 | public FileRssHandler(File file, Duration writeBodyPause) { 80 | this.file = file; 81 | this.writeBodyPause = writeBodyPause; 82 | } 83 | 84 | @Override 85 | public void handle(HttpExchange exchange) throws IOException { 86 | LOGGER.info("New connection " + Instant.now()); 87 | var responseBodyLength = Files.size(file.toPath()); 88 | exchange.sendResponseHeaders(200, responseBodyLength); 89 | 90 | try (var os = exchange.getResponseBody()) { 91 | writeResponseBody(os, responseBodyLength); 92 | } 93 | 94 | LOGGER.info("Connection closed " + Instant.now()); 95 | } 96 | 97 | private void writeResponseBody(OutputStream os, long responseBodyLength) throws IOException { 98 | byte[] buffer = new byte[128]; 99 | int readLength; 100 | int totalReadLength = 0; 101 | boolean hasPaused = false; 102 | 103 | try (var is = new FileInputStream(file)){ 104 | while ((readLength = is.read(buffer)) != -1) { 105 | totalReadLength += readLength; 106 | os.write(buffer, 0, readLength); 107 | if (isWritePause(totalReadLength, responseBodyLength) && !hasPaused) { 108 | pause(writeBodyPause); 109 | hasPaused = true; 110 | LOGGER.info("Continue to write " + Instant.now()); 111 | } 112 | } 113 | } 114 | 115 | os.flush(); 116 | } 117 | 118 | private boolean isWritePause(int length, long totalLength) { 119 | return writeBodyPause.toMillis() > 0 && length >= totalLength * 0.90; 120 | } 121 | 122 | @SuppressWarnings("java:S2925") 123 | private void pause(Duration duration) { 124 | try { 125 | TimeUnit.MILLISECONDS.sleep(duration.toMillis()); 126 | } catch (InterruptedException ignore) { 127 | Thread.currentThread().interrupt(); 128 | } 129 | } 130 | 131 | } 132 | 133 | /** 134 | * Builder for RSS server 135 | */ 136 | public static class RssServerBuilder { 137 | private int port = 8080; 138 | private String endpointPath = "/rss"; 139 | private final File file; 140 | private final Duration writeBodyPause; 141 | 142 | RssServerBuilder(File file, Duration writeBodyPause) { 143 | this.file = file; 144 | this.writeBodyPause = writeBodyPause; 145 | } 146 | 147 | /** 148 | * Port number to use. Default: 8080 149 | * @param port port number 150 | * @return builder 151 | */ 152 | public RssServerBuilder port(int port) { 153 | this.port = port; 154 | return this; 155 | } 156 | 157 | /** 158 | * The endpoint path to use. Default: /rss 159 | * @param endpointPath endpoint path 160 | * @return builder 161 | */ 162 | public RssServerBuilder endpointPath(String endpointPath) { 163 | this.endpointPath = endpointPath; 164 | return this; 165 | } 166 | 167 | /** 168 | * Builds and configures the RSS server 169 | * @return RSS server 170 | * @throws IOException if an I/O error occurs 171 | */ 172 | public RssServer build() throws IOException { 173 | return new RssServer(port, endpointPath, file, writeBodyPause); 174 | } 175 | } 176 | 177 | } 178 | -------------------------------------------------------------------------------- /src/test/java/com/apptasticsoftware/rssreader/module/itunes/ItunesRssReaderTest.java: -------------------------------------------------------------------------------- 1 | package com.apptasticsoftware.rssreader.module.itunes; 2 | 3 | import com.apptasticsoftware.rssreader.DateTime; 4 | import com.apptasticsoftware.rssreader.util.ItemComparator; 5 | import nl.jqno.equalsverifier.EqualsVerifier; 6 | import org.junit.jupiter.api.Test; 7 | 8 | import javax.net.ssl.SSLContext; 9 | import java.io.IOException; 10 | import java.io.InputStream; 11 | import java.net.http.HttpClient; 12 | import java.security.KeyManagementException; 13 | import java.security.NoSuchAlgorithmException; 14 | import java.time.Duration; 15 | import java.util.stream.Collectors; 16 | 17 | import static org.junit.jupiter.api.Assertions.*; 18 | 19 | class ItunesRssReaderTest { 20 | 21 | @Test 22 | void readItunesPodcastFeed() { 23 | var res = new ItunesRssReader().read(fromFile("itunes-podcast.xml")) 24 | .sorted(ItemComparator.oldestPublishedItemFirst()) 25 | .collect(Collectors.toList()); 26 | 27 | assertEquals(9, res.size()); 28 | } 29 | 30 | @Test 31 | void readItunesPodcastFeedFromUrl() throws IOException { 32 | var res = new ItunesRssReader().read("https://feeds.theincomparable.com/batmanuniversity") 33 | .collect(Collectors.toList()); 34 | 35 | assertFalse(res.isEmpty()); 36 | } 37 | 38 | @Test 39 | void httpClient() throws IOException, KeyManagementException, NoSuchAlgorithmException { 40 | SSLContext context = SSLContext.getInstance("TLSv1.3"); 41 | context.init(null, null, null); 42 | 43 | HttpClient httpClient = HttpClient.newBuilder() 44 | .sslContext(context) 45 | .connectTimeout(Duration.ofSeconds(15)) 46 | .followRedirects(HttpClient.Redirect.NORMAL) 47 | .build(); 48 | 49 | var res = new ItunesRssReader(httpClient).read("https://feeds.theincomparable.com/batmanuniversity") 50 | .collect(Collectors.toList()); 51 | 52 | assertFalse(res.isEmpty()); 53 | } 54 | 55 | @Test 56 | void equalsContract() { 57 | EqualsVerifier.simple().forClass(ItunesChannel.class).withIgnoredFields("dateTimeParser").withIgnoredFields("category").withNonnullFields("categories").withIgnoredFields("syUpdatePeriod").withIgnoredFields("syUpdateFrequency").withNonnullFields("itunesCategories").verify(); 58 | EqualsVerifier.simple().forClass(ItunesItem.class).withIgnoredFields("defaultComparator").withIgnoredFields("dateTimeParser").withIgnoredFields("category").withNonnullFields("categories").withIgnoredFields("enclosure").withNonnullFields("enclosures").verify(); 59 | EqualsVerifier.simple().forClass(ItunesOwner.class).verify(); 60 | } 61 | 62 | @Test 63 | void duration() { 64 | ItunesItem item = new ItunesItem(new DateTime()); 65 | item.setItunesDuration("1"); 66 | assertEquals(1, item.getItunesDurationAsDuration().get().getSeconds()); 67 | item.setItunesDuration("01:02"); 68 | assertEquals(62, item.getItunesDurationAsDuration().get().getSeconds()); 69 | item.setItunesDuration("01:02:03"); 70 | assertEquals(3723, item.getItunesDurationAsDuration().get().getSeconds()); 71 | } 72 | 73 | @Test 74 | void badDuration() { 75 | ItunesItem item = new ItunesItem(new DateTime()); 76 | item.setItunesDuration(null); 77 | assertTrue(item.getItunesDurationAsDuration().isEmpty()); 78 | item.setItunesDuration(" "); 79 | assertTrue(item.getItunesDurationAsDuration().isEmpty()); 80 | item.setItunesDuration(":"); 81 | assertTrue(item.getItunesDurationAsDuration().isEmpty()); 82 | item.setItunesDuration("a"); 83 | assertTrue(item.getItunesDurationAsDuration().isEmpty()); 84 | item.setItunesDuration("a:b"); 85 | assertTrue(item.getItunesDurationAsDuration().isEmpty()); 86 | item.setItunesDuration("a:b:c"); 87 | assertTrue(item.getItunesDurationAsDuration().isEmpty()); 88 | } 89 | 90 | private InputStream fromFile(String fileName) { 91 | return getClass().getClassLoader().getResourceAsStream(fileName); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/test/java/com/apptasticsoftware/rssreader/module/mediarss/MediaRssReaderTest.java: -------------------------------------------------------------------------------- 1 | package com.apptasticsoftware.rssreader.module.mediarss; 2 | 3 | import com.apptasticsoftware.rssreader.util.ItemComparator; 4 | import nl.jqno.equalsverifier.EqualsVerifier; 5 | import org.junit.jupiter.api.Test; 6 | 7 | import java.io.InputStream; 8 | import java.util.stream.Collectors; 9 | 10 | import static com.github.npathai.hamcrestopt.OptionalMatchers.isEmpty; 11 | import static com.github.npathai.hamcrestopt.OptionalMatchers.isPresentAnd; 12 | import static org.hamcrest.MatcherAssert.assertThat; 13 | import static org.hamcrest.Matchers.equalTo; 14 | import static org.junit.jupiter.api.Assertions.assertEquals; 15 | 16 | class MediaRssReaderTest { 17 | 18 | @Test 19 | void readMediaRssFeed() { 20 | var res = new MediaRssReader().read(fromFile("media-rss.xml")) 21 | .collect(Collectors.toList()); 22 | 23 | assertEquals(10, res.size()); 24 | } 25 | 26 | @Test 27 | void readMediaRssFeedItemTitle() { 28 | var res = new MediaRssReader().read(fromFile("media-rss.xml")) 29 | .sorted(ItemComparator.oldestPublishedItemFirst()) 30 | .collect(Collectors.toList()); 31 | 32 | MediaRssItem item = res.get(0); 33 | assertThat(item.getTitle(), isPresentAnd(equalTo("Ignitis_wind"))); 34 | } 35 | 36 | @Test 37 | void readMediaRssFeedItemPubDate() { 38 | var res = new MediaRssReader().read(fromFile("media-rss.xml")) 39 | .sorted(ItemComparator.oldestPublishedItemFirst()) 40 | .collect(Collectors.toList()); 41 | 42 | MediaRssItem item = res.get(0); 43 | assertThat(item.getPubDate(), isPresentAnd(equalTo("Mon, 07 Nov 2022 14:51:45 -0500"))); 44 | } 45 | 46 | @Test 47 | void readMediaRssFeedItemLink() { 48 | var res = new MediaRssReader().read(fromFile("media-rss.xml")) 49 | .sorted(ItemComparator.oldestPublishedItemFirst()) 50 | .collect(Collectors.toList()); 51 | 52 | MediaRssItem item = res.get(0); 53 | assertThat(item.getLink(), isPresentAnd(equalTo("https://vimeo.com/768251452"))); 54 | } 55 | 56 | @Test 57 | void readMediaRssFeedDescription() { 58 | var res = new MediaRssReader().read(fromFile("media-rss.xml")) 59 | .sorted(ItemComparator.oldestPublishedItemFirst()) 60 | .collect(Collectors.toList()); 61 | 62 | MediaRssItem item = res.get(0); 63 | assertThat(item.getDescription(), isPresentAnd(equalTo("This is "Ignitis_wind" by pvz.lt on Vimeo, the home for high quality videos and the people who love them."))); 64 | } 65 | 66 | @Test 67 | void readMediaRssFeedGuid() { 68 | var res = new MediaRssReader().read(fromFile("media-rss.xml")) 69 | .sorted(ItemComparator.oldestPublishedItemFirst()) 70 | .collect(Collectors.toList()); 71 | 72 | MediaRssItem item = res.get(0); 73 | assertThat(item.getGuid(), isPresentAnd(equalTo("tag:vimeo,2022-11-07:clip768251452"))); 74 | } 75 | 76 | @Test 77 | void readMediaRssFeedIsPermaLink() { 78 | var res = new MediaRssReader().read(fromFile("media-rss.xml")) 79 | .sorted(ItemComparator.oldestPublishedItemFirst()) 80 | .collect(Collectors.toList()); 81 | 82 | MediaRssItem item = res.get(0); 83 | assertThat(item.getIsPermaLink(), isPresentAnd(equalTo(false))); 84 | } 85 | 86 | @Test 87 | void readMediaRssFeedThumbnail() { 88 | var res = new MediaRssReader().read(fromFile("media-rss.xml")) 89 | .sorted(ItemComparator.oldestPublishedItemFirst()) 90 | .collect(Collectors.toList()); 91 | 92 | MediaRssItem item = res.get(0); 93 | MediaThumbnail mediaThumbnail = item.getMediaThumbnail().get(); 94 | assertEquals("https://i.vimeocdn.com/video/1542457228-31ab55501fdd5316663c63781ae1a37932abc4b314bcc619e3377c0ca85b859d-d_960", mediaThumbnail.getUrl()); 95 | assertThat(mediaThumbnail.getHeight(), isPresentAnd(equalTo(540))); 96 | assertThat(mediaThumbnail.getWidth(), isPresentAnd(equalTo(960))); 97 | assertThat(mediaThumbnail.getTime(), isEmpty()); 98 | } 99 | 100 | @Test 101 | void equalsContract() { 102 | EqualsVerifier.simple().forClass(MediaRssItem.class).withIgnoredFields("defaultComparator").withIgnoredFields("dateTimeParser").withIgnoredFields("category").withNonnullFields("categories").withIgnoredFields("enclosure").withNonnullFields("enclosures").verify(); 103 | } 104 | 105 | private InputStream fromFile(String fileName) { 106 | return getClass().getClassLoader().getResourceAsStream(fileName); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/test/java/com/apptasticsoftware/rssreader/util/ItemComparatorTest.java: -------------------------------------------------------------------------------- 1 | package com.apptasticsoftware.rssreader.util; 2 | 3 | import com.apptasticsoftware.rssreader.RssReader; 4 | import org.junit.jupiter.api.Test; 5 | 6 | import java.io.InputStream; 7 | import java.time.ZonedDateTime; 8 | import java.util.List; 9 | import java.util.Objects; 10 | import java.util.stream.Collectors; 11 | 12 | import static org.junit.jupiter.api.Assertions.assertTrue; 13 | 14 | @SuppressWarnings("java:S5738") 15 | class ItemComparatorTest { 16 | 17 | @Test 18 | void testSortNewestItem() { 19 | var items = new RssReader().read(fromFile("item-sort-test.xml")) 20 | .sorted(ItemComparator.newestItemFirst()) 21 | .map(i -> i.getPubDateZonedDateTime().orElse(null)) 22 | .filter(Objects::nonNull) 23 | .map(ZonedDateTime::toEpochSecond) 24 | .collect(Collectors.toList()); 25 | 26 | assertTrue(isDescendingSortOrder(items)); 27 | } 28 | 29 | @Test 30 | void testSortNewestPublishedItem() { 31 | var items = new RssReader().read(fromFile("item-sort-test.xml")) 32 | .sorted(ItemComparator.newestPublishedItemFirst()) 33 | .map(i -> i.getPubDateZonedDateTime().orElse(null)) 34 | .filter(Objects::nonNull) 35 | .map(ZonedDateTime::toEpochSecond) 36 | .collect(Collectors.toList()); 37 | 38 | assertTrue(isDescendingSortOrder(items)); 39 | } 40 | 41 | @Test 42 | void testSortNewestItemWithCustomDateTimeParser() { 43 | var items = new RssReader().setDateTimeParser(Default.getDateTimeParser()) 44 | .read(fromFile("item-sort-test.xml")) 45 | .sorted(ItemComparator.newestItemFirst()) 46 | .map(i -> i.getPubDateZonedDateTime().orElse(null)) 47 | .filter(Objects::nonNull) 48 | .map(ZonedDateTime::toEpochSecond) 49 | .collect(Collectors.toList()); 50 | 51 | assertTrue(isDescendingSortOrder(items)); 52 | } 53 | 54 | @Test 55 | void testSortNewestPublishedItemWithCustomDateTimeParser() { 56 | var items = new RssReader().setDateTimeParser(Default.getDateTimeParser()) 57 | .read(fromFile("item-sort-test.xml")) 58 | .sorted(ItemComparator.newestPublishedItemFirst()) 59 | .map(i -> i.getPubDateZonedDateTime().orElse(null)) 60 | .filter(Objects::nonNull) 61 | .map(ZonedDateTime::toEpochSecond) 62 | .collect(Collectors.toList()); 63 | 64 | assertTrue(isDescendingSortOrder(items)); 65 | } 66 | 67 | @Test 68 | void testSortNewestItemWithDateTimeParser() { 69 | var items = new RssReader().read(fromFile("item-sort-test.xml")) 70 | .sorted(ItemComparator.newestItemFirst(Default.getDateTimeParser())) 71 | .map(i -> i.getPubDateZonedDateTime().orElse(null)) 72 | .filter(Objects::nonNull) 73 | .map(ZonedDateTime::toEpochSecond) 74 | .collect(Collectors.toList()); 75 | 76 | assertTrue(isDescendingSortOrder(items)); 77 | } 78 | 79 | @Test 80 | void testSortNewestPublishedItemWithDateTimeParser() { 81 | var items = new RssReader().read(fromFile("item-sort-test.xml")) 82 | .sorted(ItemComparator.newestPublishedItemFirst(Default.getDateTimeParser())) 83 | .map(i -> i.getPubDateZonedDateTime().orElse(null)) 84 | .filter(Objects::nonNull) 85 | .map(ZonedDateTime::toEpochSecond) 86 | .collect(Collectors.toList()); 87 | 88 | assertTrue(isDescendingSortOrder(items)); 89 | } 90 | 91 | @Test 92 | void testSortOldestItemFirst() { 93 | var items = new RssReader().read(fromFile("item-sort-test.xml")) 94 | .sorted(ItemComparator.oldestItemFirst()) 95 | .map(i -> i.getPubDateZonedDateTime().orElse(null)) 96 | .filter(Objects::nonNull) 97 | .collect(Collectors.toList()); 98 | 99 | assertTrue(isAscendingSortOrder(items)); 100 | } 101 | 102 | @Test 103 | void testSortOldestPublishedItemFirst() { 104 | var items = new RssReader().read(fromFile("item-sort-test.xml")) 105 | .sorted(ItemComparator.oldestPublishedItemFirst()) 106 | .map(i -> i.getPubDateZonedDateTime().orElse(null)) 107 | .filter(Objects::nonNull) 108 | .collect(Collectors.toList()); 109 | 110 | assertTrue(isAscendingSortOrder(items)); 111 | } 112 | 113 | @Test 114 | void testSortOldestItemFirstWithDateTimeParser() { 115 | var items = new RssReader().read(fromFile("item-sort-test.xml")) 116 | .sorted(ItemComparator.oldestItemFirst(Default.getDateTimeParser())) 117 | .map(i -> i.getPubDateZonedDateTime().orElse(null)) 118 | .filter(Objects::nonNull) 119 | .collect(Collectors.toList()); 120 | 121 | assertTrue(isAscendingSortOrder(items)); 122 | } 123 | 124 | @Test 125 | void testSortOldestPublishedItemFirstWithDateTimeParser() { 126 | var items = new RssReader().read(fromFile("item-sort-test.xml")) 127 | .sorted(ItemComparator.oldestPublishedItemFirst(Default.getDateTimeParser())) 128 | .map(i -> i.getPubDateZonedDateTime().orElse(null)) 129 | .filter(Objects::nonNull) 130 | .collect(Collectors.toList()); 131 | 132 | assertTrue(isAscendingSortOrder(items)); 133 | } 134 | 135 | @Test 136 | void testSortNewestUpdatedItem() { 137 | var items = new RssReader().read(fromFile("item-sort-test.xml")) 138 | .sorted(ItemComparator.newestUpdatedItemFirst()) 139 | .map(i -> i.getUpdatedZonedDateTime().orElse(null)) 140 | .filter(Objects::nonNull) 141 | .map(ZonedDateTime::toEpochSecond) 142 | .collect(Collectors.toList()); 143 | 144 | assertTrue(isDescendingSortOrder(items)); 145 | } 146 | 147 | @Test 148 | void testSortNewestUpdatedItemWithCustomDateTimeParser() { 149 | var items = new RssReader().setDateTimeParser(Default.getDateTimeParser()) 150 | .read(fromFile("item-sort-test.xml")) 151 | .sorted(ItemComparator.newestUpdatedItemFirst()) 152 | .map(i -> i.getUpdatedZonedDateTime().orElse(null)) 153 | .filter(Objects::nonNull) 154 | .map(ZonedDateTime::toEpochSecond) 155 | .collect(Collectors.toList()); 156 | 157 | assertTrue(isDescendingSortOrder(items)); 158 | } 159 | 160 | @Test 161 | void testSortNewestUpdatedItemWithDateTimeParser() { 162 | var items = new RssReader().read(fromFile("item-sort-test.xml")) 163 | .sorted(ItemComparator.newestUpdatedItemFirst(Default.getDateTimeParser())) 164 | .map(i -> i.getUpdatedZonedDateTime().orElse(null)) 165 | .filter(Objects::nonNull) 166 | .map(ZonedDateTime::toEpochSecond) 167 | .collect(Collectors.toList()); 168 | 169 | assertTrue(isDescendingSortOrder(items)); 170 | } 171 | 172 | @Test 173 | void testSortOldestUpdatedItemFirst() { 174 | var items = new RssReader().read(fromFile("item-sort-test.xml")) 175 | .sorted(ItemComparator.oldestUpdatedItemFirst()) 176 | .map(i -> i.getUpdatedZonedDateTime().orElse(null)) 177 | .filter(Objects::nonNull) 178 | .collect(Collectors.toList()); 179 | 180 | assertTrue(isAscendingSortOrder(items)); 181 | } 182 | 183 | @Test 184 | void testSortOldestUpdatedItemFirstWithDateTimeParser() { 185 | var items = new RssReader().read(fromFile("item-sort-test.xml")) 186 | .sorted(ItemComparator.oldestUpdatedItemFirst(Default.getDateTimeParser())) 187 | .map(i -> i.getUpdatedZonedDateTime().orElse(null)) 188 | .filter(Objects::nonNull) 189 | .collect(Collectors.toList()); 190 | 191 | assertTrue(isAscendingSortOrder(items)); 192 | } 193 | 194 | @Test 195 | void testSortChannelTitle() { 196 | var urlList = List.of("https://www.theverge.com/rss/reviews/index.xml", "https://feeds.macrumors.com/MacRumors-All"); 197 | var items = new RssReader().read(urlList) 198 | .sorted(ItemComparator.channelTitle()) 199 | .map(i -> i.getChannel().getTitle()) 200 | .filter(Objects::nonNull) 201 | .collect(Collectors.toList()); 202 | 203 | assertTrue(isAscendingSortOrder(items)); 204 | } 205 | 206 | 207 | private static > boolean isAscendingSortOrder(List array){ 208 | for (int i = 0; i < array.size()-1; i++) { 209 | if (array.get(i).compareTo(array.get(i+1)) > 0){ 210 | return false; 211 | } 212 | } 213 | return true; 214 | } 215 | 216 | private static > boolean isDescendingSortOrder(List array){ 217 | for (int i = 0; i < array.size()-1; i++) { 218 | if (array.get(i).compareTo(array.get(i+1)) < 0){ 219 | return false; 220 | } 221 | } 222 | return true; 223 | } 224 | 225 | private InputStream fromFile(String fileName) { 226 | return getClass().getClassLoader().getResourceAsStream(fileName); 227 | } 228 | 229 | } 230 | -------------------------------------------------------------------------------- /src/test/java/com/apptasticsoftware/rssreader/util/MapperTest.java: -------------------------------------------------------------------------------- 1 | package com.apptasticsoftware.rssreader.util; 2 | 3 | import com.apptasticsoftware.rssreader.*; 4 | import org.junit.jupiter.api.Test; 5 | import org.junit.jupiter.params.ParameterizedTest; 6 | import org.junit.jupiter.params.provider.Arguments; 7 | import org.junit.jupiter.params.provider.MethodSource; 8 | import org.junit.jupiter.params.provider.ValueSource; 9 | 10 | import java.util.Optional; 11 | import java.util.logging.Level; 12 | import java.util.logging.Logger; 13 | import java.util.stream.Stream; 14 | 15 | import static com.apptasticsoftware.rssreader.util.Mapper.mapInteger; 16 | import static org.junit.jupiter.api.Assertions.*; 17 | 18 | class MapperTest { 19 | 20 | @ParameterizedTest 21 | @ValueSource(strings = {"true", "TRUE", "True", "yes", "YES", "Yes"}) 22 | void testMapBooleanTrue(String trueValue) { 23 | Item item = new Item(new DateTime()); 24 | Mapper.mapBoolean(trueValue, item::setIsPermaLink); 25 | assertEquals(true, item.getIsPermaLink().orElse(null)); 26 | } 27 | 28 | @ParameterizedTest 29 | @ValueSource(strings = {"false", "FALSE", "False", "no", "NO", "No"}) 30 | void testMapBooleanFalse(String falseValue) { 31 | Item item = new Item(new DateTime()); 32 | Mapper.mapBoolean(falseValue, item::setIsPermaLink); 33 | assertEquals(false, item.getIsPermaLink().orElse(null)); 34 | } 35 | 36 | @ParameterizedTest 37 | @ValueSource(strings = {"Bad value", ""}) 38 | void testMapBooleanBadValue(String falseValue) { 39 | Item item = new Item(new DateTime()); 40 | Mapper.mapBoolean(falseValue, item::setIsPermaLink); 41 | assertNull(item.getIsPermaLink().orElse(null)); 42 | } 43 | 44 | @ParameterizedTest 45 | @ValueSource(strings = {"1", "-1", "0", "12345", "-12345"}) 46 | void testMapInt(String intTextValue) { 47 | Image image = new Image(); 48 | Mapper.mapInteger(intTextValue, image::setHeight); 49 | assertEquals(Integer.valueOf(intTextValue), image.getHeight().orElse(null)); 50 | } 51 | 52 | @ParameterizedTest 53 | @ValueSource(strings = {"aaa", "a1", "1a"}) 54 | void testMapBadInt(String intTextValue) { 55 | Image image = new Image(); 56 | Mapper.mapInteger(intTextValue, image::setHeight); 57 | assertTrue(image.getHeight().isEmpty()); 58 | } 59 | 60 | @ParameterizedTest 61 | @ValueSource(strings = {"1", "-1", "0", "12345", "-12345"}) 62 | void testMapLong(String longTextValue) { 63 | Enclosure enclosure = new Enclosure(); 64 | Mapper.mapLong(longTextValue, enclosure::setLength); 65 | assertEquals(Long.valueOf(longTextValue), enclosure.getLength().orElse(null)); 66 | } 67 | 68 | @ParameterizedTest 69 | @ValueSource(strings = {"aaa", "a1", "1a"}) 70 | void testMapBadLong(String longTextValue) { 71 | Enclosure enclosure = new Enclosure(); 72 | Mapper.mapLong(longTextValue, enclosure::setLength); 73 | assertTrue(enclosure.getLength().isEmpty()); 74 | } 75 | 76 | 77 | @Test 78 | void testCreateIfNull() { 79 | Channel channel = new Channel(new DateTime()); 80 | Mapper.createIfNull(channel::getImage, channel::setImage, Image::new).setTitle("title"); 81 | assertEquals("title", channel.getImage().map(Image::getTitle).orElse("-")); 82 | Mapper.createIfNull(channel::getImage, channel::setImage, Image::new).setUrl("url"); 83 | assertEquals("url", channel.getImage().map(Image::getUrl).orElse("-")); 84 | assertEquals("title", channel.getImage().map(Image::getTitle).orElse("-")); 85 | } 86 | 87 | @Test 88 | void testCreateIfNullOptional() { 89 | Channel channel = new Channel(new DateTime()); 90 | Mapper.createIfNullOptional(channel::getImage, channel::setImage, Image::new).ifPresent(i -> mapInteger("200", i::setHeight)); 91 | assertEquals(200, channel.getImage().flatMap(Image::getHeight).orElse(0)); 92 | Mapper.createIfNullOptional(channel::getImage, channel::setImage, Image::new).ifPresent(i -> mapInteger("100", i::setWidth)); 93 | assertEquals(100, channel.getImage().flatMap(Image::getWidth).orElse(0)); 94 | assertEquals(200, channel.getImage().flatMap(Image::getHeight).orElse(0)); 95 | } 96 | 97 | @Test 98 | void testBadNumberLogging() { 99 | var logger = Logger.getLogger("com.apptasticsoftware.rssreader.util"); 100 | logger.setLevel(Level.ALL); 101 | 102 | var image = new Image(); 103 | Mapper.mapInteger("-", image::setHeight); 104 | assertEquals(Optional.empty(), image.getHeight()); 105 | 106 | logger.setLevel(Level.OFF); 107 | Mapper.mapInteger("-", image::setHeight); 108 | assertEquals(Optional.empty(), image.getHeight()); 109 | 110 | Mapper.mapInteger("", image::setHeight); 111 | assertEquals(Optional.empty(), image.getHeight()); 112 | 113 | Mapper.mapInteger(null, image::setHeight); 114 | assertEquals(Optional.empty(), image.getHeight()); 115 | } 116 | 117 | @ParameterizedTest 118 | @MethodSource("mapIfEmptyParameters") 119 | void testMapIfEmpty(TestObject testObject, String value, String expected) { 120 | Mapper.mapIfEmpty(value, testObject::getText, testObject::setText); 121 | assertEquals(expected, testObject.getText()); 122 | } 123 | 124 | @ParameterizedTest 125 | @MethodSource("mapIfEmptyParameters") 126 | void testOptionalMapIfEmpty(TestObject testObject, String value, String expected) { 127 | Mapper.mapIfEmpty(value, testObject::getOptionalText, testObject::setText); 128 | assertEquals(expected, testObject.getText()); 129 | } 130 | 131 | private static Stream mapIfEmptyParameters() { 132 | return Stream.of( 133 | Arguments.of(new TestObject(null), "value", "value"), 134 | Arguments.of(new TestObject(""), "value", "value"), 135 | Arguments.of(new TestObject(null), "", null), 136 | Arguments.of(new TestObject(null), null, null), 137 | Arguments.of(new TestObject(""), "", ""), 138 | Arguments.of(new TestObject(""), null, ""), 139 | Arguments.of(new TestObject("value"), "other value", "value") 140 | ); 141 | } 142 | 143 | 144 | static class TestObject { 145 | private String text; 146 | 147 | public TestObject(String value) { 148 | text = value; 149 | } 150 | 151 | public void setText(String value) { 152 | text = value; 153 | } 154 | 155 | public String getText() { 156 | return text; 157 | } 158 | 159 | public Optional getOptionalText() { 160 | return Optional.ofNullable(text); 161 | } 162 | 163 | } 164 | 165 | } 166 | -------------------------------------------------------------------------------- /src/test/java/com/apptasticsoftware/rssreader/util/UtilTest.java: -------------------------------------------------------------------------------- 1 | package com.apptasticsoftware.rssreader.util; 2 | 3 | import org.junit.jupiter.params.ParameterizedTest; 4 | import org.junit.jupiter.params.provider.Arguments; 5 | import org.junit.jupiter.params.provider.MethodSource; 6 | 7 | import java.util.stream.Stream; 8 | 9 | import static org.junit.jupiter.api.Assertions.assertEquals; 10 | 11 | class UtilTest { 12 | 13 | @ParameterizedTest(name = "{0} is expected to output {1}") 14 | @MethodSource("periodToHoursTestData") 15 | void periodToHours(String period, int expectedHours) { 16 | assertEquals(expectedHours, Util.toMinutes(period)); 17 | } 18 | 19 | private static Stream periodToHoursTestData() { 20 | return Stream.of( 21 | Arguments.of("daily", 1440), 22 | Arguments.of("weekly", 10080), 23 | Arguments.of("monthly", 43800), 24 | Arguments.of("yearly", 525600), 25 | Arguments.of("hourly", 60), 26 | Arguments.of("unknown", 60) 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/test/resources/atom-feed-category.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | FYI Center for Software Developers 4 | FYI (For Your Information) Center for Software Developers with 5 | large collection of FAQs, tutorials and tips codes for application and 6 | wWeb developers on Java, .NET, C, PHP, JavaScript, XML, HTML, CSS, RSS, 7 | MySQL and Oracle - dev.fyicenter.com. 8 | https://example.com/logo.png 9 | 10 | http://dev.fyicenter.com/atom_xml.php 11 | 2017-09-22T03:58:52+02:00 12 | 13 | FYIcenter.com 14 | 15 | Copyright (c) 2017 FYIcenter.com 16 | 17 | 18 | 19 | 20 | Use Developer Portal Internally 21 | 24 | 25 | http://dev.fyicenter.com/1000702_Use_Developer_Portal_Internally.html 26 | 27 | 2017-09-20T13:29:08+02:00 28 |

<img align='left' width='64' height='64' 29 | src='http://dev.fyicenter.com/Azure-API/_icon_Azure-API.png' />How to 30 | use the Developer Portal internally by you as the publisher? Normally, 31 | the Developer Portal of an Azure API Management Service is used by 32 | client developers. But as a publisher, you can also use the Developer 33 | Portal to test API operations internally. You can follow this tutorial 34 | to access the ... - Rank: 120; Updated: 2017-09-20 13:29:06 -> <a 35 | href='http://dev.fyicenter.com/1000702_Use_Developer_Portal_Internally.ht 36 | ml'>Source</a> 37 | 38 | FYIcenter.com 39 | 40 | 41 | 42 | 43 | 44 | Using Azure API Management Developer Portal 45 | 48 | 49 | http://dev.fyicenter.com/1000701_Using_Azure_API_Management_Developer 50 | _Portal.html 51 | 2017-09-20T13:29:07+02:00 52 | <img align='left' width='64' height='64' 53 | src='http://dev.fyicenter.com/Azure-API/_icon_Azure-API.png' />Where to 54 | find tutorials on Using Azure API Management Developer Portal? Here is 55 | a list of tutorials to answer many frequently asked questions compiled 56 | by FYIcenter.com team on Using Azure API Management Developer Portal: 57 | Use Developer Portal Internally What Can I See on Developer Portal What 58 | I You T... - Rank: 120; Updated: 2017-09-20 13:29:06 -> <a 59 | href='http://dev.fyicenter.com/1000701_Using_Azure_API_Management_Develop 60 | er_Portal.html'>Source</a> 61 | 62 | FYIcenter.com 63 | 64 | 65 | 66 | 67 | Add API to API Products 68 | 70 | http://dev.fyicenter.com/1000700_Add_API_to_API_Products.html 71 | 2017-09-20T13:29:06+02:00 72 | <img align='left' width='64' height='64' 73 | src='http://dev.fyicenter.com/Azure-API/_icon_Azure-API.png' />How to 74 | add an API to an API product for internal testing on the Publisher 75 | Portal of an Azure API Management Service? You can follow this tutorial 76 | to add an API to an API product on the Publisher Portal of an Azure API 77 | Management Service. 1. Click API from the left menu on the Publisher 78 | Portal. You s... - Rank: 119; Updated: 2017-09-20 13:29:06 -> <a 79 | href='http://dev.fyicenter.com/1000700_Add_API_to_API_Products.html'>Sour 80 | ce</a> 81 | 82 | FYIcenter.com 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /src/test/resources/atom-feed.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | dive into mark 4 | 5 | A <em>lot</em> of effort 6 | went into making this effortless 7 | 8 | https://example.com/icon.png 9 | 2005-07-31T12:29:29Z 10 | tag:example.org,2003:3 11 | 13 | 15 | Copyright (c) 2003, Mark Pilgrim 16 | 17 | Example Toolkit 18 | 19 | 20 | Atom draft-07 snapshot 21 | 23 | 25 | tag:example.org,2003:3.2397 26 | 2005-07-31T12:29:29Z 27 | 2003-12-13T08:29:29-04:00 28 | 29 | Mark Pilgrim 30 | http://example.org/ 31 | f8dy@example.com 32 | 33 | 34 | Sam Ruby 35 | 36 | 37 | Joe Gregorio 38 | 39 | 41 |
42 |

[Update: The Atom draft is finished.]

43 |
44 |
45 |
46 | 47 | 48 | 50 | 10 51 | John 52 | Doe 53 | 54 | 55 | Atom-Powered Robots Run Amok 56 | 57 | urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a 58 | 2003-12-13T18:30:02Z 59 | 60 | 61 | 62 | {"firstName"="John","lastName"="Doe","id"="10"} 63 | 64 | Atom-Powered Robots Run Amok 2 65 | 66 | urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6b 67 | 2003-12-13T09:28:28-04:00 68 | 2003-12-13T18:30:01Z 69 | 70 |
-------------------------------------------------------------------------------- /src/test/resources/bad-image-width-height.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | title 5 | https://test.com/ 6 | Test channel 7 | testing 8 | Tue, 29 Nov 2022 13:49:44 +0100 9 | Tue, 29 Nov 2022 13:49:44 +0100 10 | en 11 | https://test.com/generator 12 | testing@test.com 13 | webmaster@test.com 14 | 120 15 | 16 | https://www.test.com/testing.jpg 17 | testing 18 | 19 | not-a-number 20 | 21 | 22 | 23 | test item 1 24 | Sun, 27 Nov 2022 00:00:00 +0100 25 | 26 | test-id-1 27 | A test item 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/test/resources/empty-category.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | GameSpot - All News 4 | https://www.gamespot.com/feeds/news 5 | The latest News from GameSpot 6 | en-us 7 | Sun, 04 Dec 2022 23:33:42 -0800 8 | 9 | 10 | 11 | Today's Wordle Answer (#534) - December 5, 2022 12 | https://www.gamespot.com/articles/todays-wordle-answer-534-december-5-2022/1100-6509697/?ftag=CAD-01-10abi2f 13 | 14 | It's Monday and that can only mean one thing: We're back for another week of Wordle guides. After a long weekend, it's time to get back into the swing of things, and we're here to make sure you do that by getting the Wordle correct. Today's answer doesn't do players any favors, as it's definitely not a word that many users will think of quickly. If you haven't started the December 5 Wordle just yet, then you can check out our list of recommended starting words. However, if you're already past the starting point and visiting this article, then you might be in need of some help.

That's where we can come in. Below, players will find two tips for today's Wordle. We've also spelled out the full answer for players who might not find the hints helpful enough.

Today's Wordle Answer - December 5, 2022

We'll begin with a couple of hints that directly relate to the answer, but won't give it away.

Continue Reading at GameSpot ]]> 15 |
16 | Sun, 04 Dec 2022 18:40:00 -0800 17 | 1100-6509697 18 | Joey Carr 19 | 20 | 557333 21 | 22 |
23 |
> 24 |
25 | -------------------------------------------------------------------------------- /src/test/resources/itunes-podcast.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Hiking Treks 5 | https://www.apple.com/itunes/podcasts/ 6 | en-us 7 | © 2020 John Appleseed 8 | The Sunset Explorers 9 | 10 | Love to get outdoors and discover nature's treasures? Hiking Treks is the 11 | show for you. We review hikes and excursions, review outdoor gear and interview 12 | a variety of naturalists and adventurers. Look for new episodes each week. 13 | 14 | serial 15 | 16 | Sunset Explorers 17 | mountainscape@icloud.com 18 | 19 | 22 | 23 | 24 | 25 | false 26 | 27 | trailer 28 | Hiking Treks Trailer 29 | 30 | Apple Podcasts.]]> 33 | 34 | 39 | aae20190418 40 | Tue, 8 Jan 2019 01:15:00 GMT 41 | 1079 42 | false 43 | 44 | 45 | full 46 | 4 47 | 2 48 | S02 EP04 Mt. Hood, Oregon 49 | 50 | Tips for trekking around the tallest mountain in Oregon 51 | 52 | 57 | aae20190606 58 | Tue, 07 May 2019 12:00:00 GMT 59 | 1024 60 | false 61 | 62 | 63 | full 64 | 3 65 | 2 66 | S02 EP03 Bouldering Around Boulder 67 | 68 | We explore fun walks to climbing areas about the beautiful Colorado city of Boulder. 69 | 70 | 73 | href="http://example.com/podcasts/everything/ 74 | 79 | aae20190530 80 | Tue, 30 Apr 2019 13:00:00 EST 81 | 3627 82 | false 83 | 84 | 85 | full 86 | 2 87 | 2 88 | S02 EP02 Caribou Mountain, Maine 89 | 90 | Put your fitness to the test with this invigorating hill climb. 91 | 92 | 95 | 100 | aae20190523 101 | Tue, 23 May 2019 02:00:00 -0700 102 | 2434 103 | false 104 | 105 | 106 | full 107 | 1 108 | 2 109 | S02 EP01 Stawamus Chief 110 | 111 | We tackle Stawamus Chief outside of Vancouver, BC and you should too! 112 | 113 | 118 | aae20190516 119 | 2019-02-16T07:00:00.000Z 120 | 13:24 121 | false 122 | 123 | 124 | full 125 | 4 126 | 1 127 | S01 EP04 Kuliouou Ridge Trail 128 | 129 | Oahu, Hawaii, has some picturesque hikes and this is one of the best! 130 | 131 | 136 | aae20190509 137 | Tue, 27 Nov 2018 01:15:00 +0000 138 | 929 139 | false 140 | 141 | 142 | full 143 | 3 144 | 1 145 | S01 EP03 Blood Mountain Loop 146 | 147 | Hiking the Appalachian Trail and Freeman Trail in Georgia 148 | 149 | 154 | aae20190502 155 | Tue, 23 Oct 2018 01:15:00 +0000 156 | 1440 157 | false 158 | 159 | 160 | full 161 | 2 162 | 1 163 | S01 EP02 Garden of the Gods Wilderness 164 | 165 | Wilderness Area Garden of the Gods in Illinois is a delightful spot for 166 | an extended hike. 167 | 168 | 173 | aae20190425 174 | Tue, 18 Sep 2018 01:15:00 +0000 175 | 839 176 | false 177 | 178 | 179 | full 180 | 1 181 | 1 182 | S01 EP01 Upper Priest Lake Trail to Continental Creek Trail 183 | 184 | We check out this powerfully scenic hike following the river in the Idaho 185 | Panhandle National Forests. 186 | 187 | 192 | aae20190418a 193 | Tue, 14 Aug 2018 01:15:00 +0000 194 | 1399 195 | false 196 | 197 | 198 | 199 | -------------------------------------------------------------------------------- /src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker: -------------------------------------------------------------------------------- 1 | mock-maker-inline 2 | -------------------------------------------------------------------------------- /src/test/resources/multiple-categories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | NYT > Top Stories 4 | https://www.nytimes.com 5 | NYT > channel description> 6 | en-us 7 | Copyright 2022 The New York Times Company 8 | Sat, 03 Dec 2022 11:41:48 +0000 9 | Sat, 03 Dec 2022 11:33:26 +0000 10 | News 11 | China 12 | 13 | NYT > Top Stories image title 14 | NYT > image description 15 | https://static01.nyt.com/images/misc/NYT_logo_rss_250x40.png 16 | https://www.nytimes.com 17 | 18 | 19 | After Fanning Covid Fears, China Must Now Try to Allay Them 20 | https://www.nytimes.com/2022/12/03/business/china-zero-covid.html 21 | https://www.nytimes.com/2022/12/03/business/china-zero-covid.html 22 | Beijing had long warned that the only effective response was testing, quarantine and lockdowns. As it shifts policy, it must change how it portrays the risks. 23 | Sat, 03 Dec 2022 10:02:47 +0000 24 | Beijing (China) 25 | China 26 | Guangxi (China) 27 | Guangzhou (China) 28 | Coronavirus (2019-nCoV) 29 | Politics and Government 30 | Quarantines 31 | Propaganda 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/test/resources/multiple-enclosures.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | World of Tanks News | World of Tanks 7 | https://worldoftanks.eu/en/news/ 8 | The latest news, updates, specials, and events for World of Tanks, the team-based, MMO tank battle game from Wargaming. Everything about WoT in one place. 9 | en 10 | Fri, 02 Feb 2024 11:05:26 GMT 11 | 12 | https://worldoftanks.eu/static/5.132.2_d0fd33/portalnews/img/news.png 13 | World of Tanks News | World of Tanks 14 | https://worldoftanks.eu/en/news/ 15 | 16 | 17 | A Warrior's Path: Unleash the Power of Japanese Tanks With Special Bundles! 18 | https://worldoftanks.eu/en/news/specials/a-warriors-path-sale-feb-2024/ 19 | Embark on an epic journey with the A Warrior's Path event, featuring exclusive bundle sales for the newly introduced Japanese heavy tanks. 20 | https://worldoftanks.eu/en/news/specials/a-warriors-path-sale-feb-2024/ 21 | Thu, 01 Feb 2024 09:00:00 GMT 22 | Specials 23 | 24 | 25 | 26 | 27 | 28 | WoT Monthly: Valentine's Day and More in February 2024 29 | https://worldoftanks.eu/en/news/general-news/wot-monthly-february-2024/ 30 | A quick look at World of Tanks events during the month of love! 31 | https://worldoftanks.eu/en/news/general-news/wot-monthly-february-2024/ 32 | Wed, 31 Jan 2024 14:00:00 GMT 33 | General News 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/test/resources/multiple-title-on-different-levels.xml: -------------------------------------------------------------------------------- 1 | 2 | AC Social 3 | Tiny Tiny RSS/22.01-4a4928e (Unsupported) 4 | 2022-01-04T14:02:01+00:00 5 | https://reader.lanath.fr/public.php?op=rss&id=-1027&key=sso1m061d4298ba0860 6 | 7 | 8 | 9 | tag:reader.lanath.fr,2022-01-09:/1961725 10 | 11 | Créer un timelapse d’images satellites 12 | 13 | Vous vous passionnez pour les images satellites et vous aimeriez bien faire un timelapse d’un...

]]> 14 |
15 | 16 |

Vous vous passionnez pour les images satellites et vous aimeriez bien faire un timelapse d’une zone particulière de la planète pour montrer son évolution ?

Et bien avec Streamlit c’est possible. Le principe est simple. Vous sélectionnez une zone sur la carte, vous exportez cette zone dans un fichier json. Vous réimportez ensuite ce json, vous choisissez une collection d’images satellites et vous cliquez sur le bouton « Submit ».

Et voilà, vous aurez un joli GIF animé ou MP4 à télécharger. Je vous laisse regarder les vidéos pour voir ce que ça donne.

]]> 17 |
18 | 2022-01-09T08:00:00+00:00 19 | 20 | Korben 21 | 22 | 23 | http://korben.info 24 | 25 | 2022-01-09T08:00:00+00:00 26 | Korben 27 | 28 | 29 | 30 | 31 | 32 |
33 |
34 | -------------------------------------------------------------------------------- /src/test/resources/podcast-with-bad-enclosure.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 1LIVE Fiehe 4 | https://fiehe.info/ 5 | 6 | Korrektes Zeug 7 | Music 8 | Tue, 29 Nov 2022 13:49:44 +0100 9 | Tue, 29 Nov 2022 13:49:44 +0100 10 | de-de 11 | https://fiehe.info 12 | kontakt@fiehe.info (Fiehe.info) 13 | kontakt@fiehe.info (Fiehe.info) 14 | 360 15 | Klaus Fiehe 16 | Korrektes Zeug 17 | 1Live Fiehe als Podcast, bereitgestellt von Fiehe.info. 18 | 19 | no 20 | 21 | Fiehe.info 22 | kontakt@fiehe.info 23 | 24 | 25 | 26 | https://www1.wdr.de/radio/1live/team/moderatoren/klaus-fiehe-sw-100~_v-gseapremiumxl.jpg 27 | 1LIVE Fiehe 28 | 29 | 30 | 31 | 27.11.2022 32 | Sun, 27 Nov 2022 00:00:00 +0100 33 | 34 | https://fiehe.info/1live-fiehe/2022-11-27/ 35 | Hammock – Untruth 36 | 2:52:00 37 | Eine bekannt wunderbare Sendung von Klaus Fiehe vom 27.11.2022, unter anderem mit Titeln von Unknown Cases feat. Reebop Kwaku Baah, CEO Trayle und Mountain. 38 | 39 | 40 | 41 | --------------------------------------------------------------------------------