diff --git a/CHANGES.md b/CHANGES.md
index 5ea137a463..d30f61c9c7 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -158,6 +158,7 @@
that added its own commons-logging bridge should remove it.
- The source distribution now extracts to `apache-roller--source/`,
matching its file name, instead of `roller-weblogger--source/`.
+- ROME, the feed library Planet uses, is upgraded from 1.19.0 to 2.1.0.
- The admin UI now uses jQuery 4.0.0 and jQuery Validation 1.22.1.
## 6.1.7
diff --git a/app/pom.xml b/app/pom.xml
index 09a41462c4..49574a3e66 100644
--- a/app/pom.xml
+++ b/app/pom.xml
@@ -52,7 +52,7 @@ limitations under the License.
3.5.13.6.01.0b3
- 1.19.0
+ 2.1.02.0.207.0.107.1.2
diff --git a/app/src/main/java/org/apache/roller/planet/business/fetcher/RomeFeedFetcher.java b/app/src/main/java/org/apache/roller/planet/business/fetcher/RomeFeedFetcher.java
index 1f38660044..eb2d275279 100644
--- a/app/src/main/java/org/apache/roller/planet/business/fetcher/RomeFeedFetcher.java
+++ b/app/src/main/java/org/apache/roller/planet/business/fetcher/RomeFeedFetcher.java
@@ -23,6 +23,7 @@
import com.rometools.rome.feed.synd.SyndContent;
import com.rometools.rome.feed.synd.SyndEntry;
import com.rometools.rome.feed.synd.SyndFeed;
+import com.rometools.rome.feed.synd.SyndLink;
import com.rometools.rome.io.FeedException;
import com.rometools.rome.io.SyndFeedInput;
import com.rometools.rome.io.XmlReader;
@@ -100,7 +101,7 @@ public Subscription fetchSubscription(String feedURL, Date lastModified) throws
// build planet subscription from fetched feed
Subscription newSub = new Subscription();
newSub.setFeedURL(feedURL);
- newSub.setSiteURL(feed.getLink());
+ newSub.setSiteURL(siteURL(feed));
newSub.setTitle(feed.getTitle());
newSub.setAuthor(feed.getAuthor());
newSub.setLastUpdated(feed.getPublishedDate());
@@ -159,6 +160,28 @@ public Subscription fetchSubscription(String feedURL, Date lastModified) throws
}
+ // ROME 2 makes an Atom feed's rel="self" link (the feed itself) its link,
+ // so look for the alternate link to the site first, preferring an HTML
+ // one over alternates in other formats (another feed, JSON). RSS feeds
+ // have no links list and keep using the channel link.
+ private static String siteURL(SyndFeed feed) {
+ String firstAlternate = null;
+ for (SyndLink link : feed.getLinks()) {
+ String rel = link.getRel();
+ if (StringUtils.isBlank(rel) || "alternate".equals(rel)) {
+ String type = link.getType();
+ if (StringUtils.isBlank(type) || "text/html".equalsIgnoreCase(type)) {
+ return link.getHref();
+ }
+ if (firstAlternate == null) {
+ firstAlternate = link.getHref();
+ }
+ }
+ }
+ return firstAlternate != null ? firstAlternate : feed.getLink();
+ }
+
+
// build a SubscriptionEntry from Rome SyndEntry and SyndFeed
private SubscriptionEntry buildEntry(SyndEntry romeEntry) {
diff --git a/app/src/test/java/org/apache/roller/planet/business/fetcher/RomeFeedFetcherLocalTest.java b/app/src/test/java/org/apache/roller/planet/business/fetcher/RomeFeedFetcherLocalTest.java
new file mode 100644
index 0000000000..bca3e046ba
--- /dev/null
+++ b/app/src/test/java/org/apache/roller/planet/business/fetcher/RomeFeedFetcherLocalTest.java
@@ -0,0 +1,298 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License. For additional information regarding
+ * copyright in this work, please see the NOTICE file in the top level
+ * directory of this distribution.
+ */
+
+package org.apache.roller.planet.business.fetcher;
+
+import com.rometools.rome.feed.synd.SyndEntry;
+import com.rometools.rome.feed.synd.impl.Converters;
+import com.rometools.rome.io.ParsingFeedException;
+import com.rometools.rome.io.SyndFeedInput;
+import com.rometools.rome.io.impl.FeedParsers;
+import com.sun.net.httpserver.HttpServer;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.StringReader;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.Date;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.apache.roller.planet.pojos.Subscription;
+import org.apache.roller.planet.pojos.SubscriptionEntry;
+import org.apache.roller.planet.util.rome.ContentModule;
+import org.apache.roller.planet.util.rome.PlanetConverterForRSS091N;
+import org.apache.roller.planet.util.rome.PlanetConverterForRSS091U;
+import org.apache.roller.planet.util.rome.PlanetConverterForRSS20;
+import org.apache.roller.planet.util.rome.PlanetRSS091UParser;
+import org.apache.roller.weblogger.util.SafeSAXBuilder;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Fetches feeds from a loopback HTTP server, so the mapping from ROME's model
+ * to Planet's is checked without reaching the network.
+ */
+class RomeFeedFetcherLocalTest {
+
+ private static final String ATOM = """
+
+
+ Atom Blog
+ urn:example:atom
+
+
+ 2026-10-12T08:30:00Z
+
+ urn:example:atom:1
+ Atom entry
+ Entry Author
+
+ 2026-10-10T20:00:00-04:00
+ 2026-10-11T09:15:00+02:00
+
+
+ <p>Atom body</p>
+
+
+ """;
+
+ private static final String RSS_20 = """
+
+
+
+ RSS Blog
+ https://rss.example.org/
+
+ RSS 2.0 feed
+
+ With pubDate
+ https://rss.example.org/1
+ Sat, 10 Oct 2026 20:00:00 -0400
+ Dee Creator
+ planet
+ Summary one
+ Full text one
]]>
+
+
+ With dc:date
+ https://rss.example.org/2
+ 2026-10-09T12:00:00+02:00
+ Summary two
+
+
+
+ """;
+
+ private static final String RSS_091 = """
+
+
+
+ Old Blog
+ https://old.example.org/
+ RSS 0.91 feed
+ en-us
+
+ Old item
+ https://old.example.org/1
+ Old text
+ Fri, 09 Oct 2026 08:00:00 GMT
+
+
+
+ """;
+
+ private static final Map FEEDS = new ConcurrentHashMap<>();
+
+ private static HttpServer server;
+
+ private final RomeFeedFetcher fetcher = new RomeFeedFetcher();
+
+ @BeforeAll
+ static void startServer() throws IOException {
+ server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
+ server.createContext("/", exchange -> {
+ String body = FEEDS.get(exchange.getRequestURI().getPath());
+ byte[] bytes = body == null ? new byte[0] : body.getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().set("Content-Type", "application/xml; charset=utf-8");
+ exchange.sendResponseHeaders(body == null ? 404 : 200, bytes.length == 0 ? -1 : bytes.length);
+ try (OutputStream out = exchange.getResponseBody()) {
+ out.write(bytes);
+ }
+ });
+ server.start();
+ }
+
+ @AfterAll
+ static void stopServer() {
+ server.stop(0);
+ }
+
+ @Test
+ void atomFeedUsesAlternateLinkAsSiteUrl() throws Exception {
+ String url = serve("/atom", ATOM);
+
+ Subscription sub = fetcher.fetchSubscription(url);
+
+ assertEquals(url, sub.getFeedURL());
+ // ROME 2 reports the rel="self" link as the feed link; Planet wants the site
+ assertEquals("https://atom.example.org/", sub.getSiteURL());
+ assertEquals("Atom Blog", sub.getTitle());
+ assertEquals(Instant.parse("2026-10-12T08:30:00Z"), sub.getLastUpdated().toInstant());
+
+ SubscriptionEntry entry = entry(sub, "https://atom.example.org/entry/1");
+ assertEquals("Atom entry", entry.getTitle());
+ assertEquals("Entry Author", entry.getAuthor());
+ assertEquals(timestamp("2026-10-11T00:00:00Z"), entry.getPubTime());
+ assertEquals(timestamp("2026-10-11T07:15:00Z"), entry.getUpdateTime());
+ assertEquals("