Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,22 @@ total = feed.count
feed[0].title # first item
feed[-1].title # last item

# Get the n most recent items (sorted by pubDate or updated)
# Get the n most recent items
feed.latest(10)
```

`latest`, `items_since`, and merge ordering use the first successfully parsed date
from `pubDate`, `updated`, and `published`, in that order. Invalid date strings
remain available in the original fields. `latest` places entries without a usable
date after dated entries, including dates before 1970. Equal dates and undated
entries retain their source order, and `latest` does not modify the feed.

`items_since(time)` returns entries strictly newer than the given time in source
order, excluding entries without a usable date. Merging sorts identified entries
by the same date rules and keeps the newest entry for each identity. Equal dates
keep the first occurrence. Entries without an identity remain at the end in input
order, regardless of their dates.

### JSON Serialization

```ruby
Expand Down
20 changes: 10 additions & 10 deletions lib/simple-rss.rb
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ def [](index)
#
# @rbs (?Integer) -> Array[Hash[Symbol, untyped]]
def latest(count = 10)
items.sort_by { |item| item[:pubDate] || item[:updated] || Time.at(0) }.reverse.first(count)
sorted_items_by_date(items).first(count)
end

# @rbs () -> Symbol
Expand Down Expand Up @@ -123,8 +123,8 @@ def valid?
# @rbs (Time) -> Array[Hash[Symbol, untyped]]
def items_since(time)
items.select do |item|
item_date = item[:pubDate] || item[:updated] || item[:published]
item_date.is_a?(Time) && item_date > time
date = item_date(item)
date && date > time
end
end

Expand Down Expand Up @@ -152,7 +152,8 @@ def search(query)
# @rbs (*SimpleRSS) -> Array[Hash[Symbol, untyped]]
def merge(*feeds)
all_items = [items, *feeds.map(&:items)].flatten
dedupe_items(sorted_items_by_date(all_items))
keyed_items, unkeyed_items = all_items.partition { |item| !item_key(item).nil? }
dedupe_items(sorted_items_by_date(keyed_items) + unkeyed_items)
end

# @rbs (SimpleRSS) -> Hash[Symbol, Array[Hash[Symbol, untyped]]]
Expand Down Expand Up @@ -579,8 +580,10 @@ def select_new_keyed_items(item_list, known_keys)

# @rbs (Array[Hash[Symbol, untyped]]) -> Array[Hash[Symbol, untyped]]
def sorted_items_by_date(item_list)
keyed_items, unkeyed_items = item_list.partition { |item| !item_key(item).nil? }
keyed_items.sort_by { |item| item_date(item) || Time.at(0) }.reverse + unkeyed_items
item_list.sort_by.with_index do |item, index|
date = item_date(item)
[date ? -date.to_r : Float::INFINITY, index]
end
end

# @rbs (Array[Hash[Symbol, untyped]]) -> Array[Hash[Symbol, untyped]]
Expand Down Expand Up @@ -612,10 +615,7 @@ def item_key(item)

# @rbs (Hash[Symbol, untyped]) -> Time?
def item_date(item)
date = item[:pubDate] || item[:updated] || item[:published]
return date if date.is_a?(Time)

nil
[item[:pubDate], item[:updated], item[:published]].find { |date| date.is_a?(Time) }
end

# @rbs (Hash[Symbol, untyped]) -> Array[String]
Expand Down
95 changes: 95 additions & 0 deletions test/base/date_ordering_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
require "test_helper"

class DateOrderingTest < Test::Unit::TestCase
def setup
@feed = SimpleRSS.parse(File.read(File.join(__dir__, "../data/mixed_dates.xml")))
end

def test_latest_uses_valid_fallbacks_and_preserves_equal_date_order
assert_equal [
"Updated first", "Updated second", "Fractional", "Published", "Publication",
Comment thread
cardmagic marked this conversation as resolved.
"Historical", "Missing first", "Blank", "Invalid"
Comment thread
cardmagic marked this conversation as resolved.
], @feed.latest.map(&:title)
end

def test_latest_limits_results_without_changing_original_entries
original = @feed.to_hash

assert_equal ["Updated first", "Updated second", "Fractional"], @feed.latest(3).map(&:title)
Comment thread
cardmagic marked this conversation as resolved.
assert_equal [], @feed.latest(0)
assert_same @feed.items[3], @feed.latest.first
assert_equal original, @feed.to_hash
end

def test_latest_sorts_atom_entries_with_only_published_dates
feed = SimpleRSS.parse <<~XML
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Publication dates</title>
<entry><title>Newer</title><published>2026-09-03T00:00:00Z</published></entry>
<entry><title>Older</title><published>2026-09-01T00:00:00Z</published></entry>
</feed>
XML

assert_equal %w[Newer Older], feed.latest.map(&:title)
end

def test_items_since_uses_valid_fallbacks_and_excludes_undated_entries
assert_equal ["Fractional", "Updated first", "Published", "Updated second"],
Comment thread
cardmagic marked this conversation as resolved.
@feed.items_since(Time.iso8601("2026-09-01T00:00:00Z")).map(&:title)
Comment thread
cardmagic marked this conversation as resolved.
assert_equal ["Fractional", "Updated first", "Updated second"],
Comment thread
cardmagic marked this conversation as resolved.
@feed.items_since(Time.iso8601("2026-09-02T00:00:00Z")).map(&:title)
Comment thread
cardmagic marked this conversation as resolved.
assert_equal [], @feed.items_since(Time.iso8601("2026-09-04T00:00:00Z"))
Comment thread
cardmagic marked this conversation as resolved.
end

def test_merge_sorts_dated_entries_before_undated_identified_entries
assert_equal [
"Updated first", "Updated second", "Fractional", "Published", "Publication",
Comment thread
cardmagic marked this conversation as resolved.
"Historical", "Blank", "Invalid", "Missing first"
Comment thread
cardmagic marked this conversation as resolved.
], @feed.merge.map(&:title)
end

def test_merge_uses_fallback_dates_to_dedupe_and_keeps_unidentified_entries_last
Comment thread
cardmagic marked this conversation as resolved.
other_feed = SimpleRSS.parse <<~XML
<rss version="2.0">
<channel>
<title>Other feed</title>
<item>
<guid>updated-first</guid>
<title>Replacement</title>
<pubDate>not-a-date</pubDate>
<published>2026-09-04T00:00:00Z</published>
</item>
<item><title>Unidentified newest</title><pubDate>2030-01-01T00:00:00Z</pubDate></item>
</channel>
</rss>
XML
original = @feed.to_hash
other_original = other_feed.to_hash

assert_equal [
"Replacement", "Updated second", "Fractional", "Published", "Publication",
Comment thread
cardmagic marked this conversation as resolved.
"Historical", "Blank", "Invalid", "Missing first", "Unidentified newest"
Comment thread
cardmagic marked this conversation as resolved.
], SimpleRSS.merge(@feed, other_feed).map(&:title)
assert_equal original, @feed.to_hash
assert_equal other_original, other_feed.to_hash
end

def test_merge_keeps_the_first_duplicate_when_dates_are_equal
Comment thread
cardmagic marked this conversation as resolved.
other_feed = SimpleRSS.parse <<~XML
<rss version="2.0">
<channel>
<title>Other feed</title>
<item>
<guid>updated-second</guid>
<title>Later duplicate</title>
<updated>2026-09-03T00:00:00.000000002Z</updated>
</item>
</channel>
</rss>
XML

duplicates = @feed.merge(other_feed).select { |item| item[:guid] == "updated-second" }
Comment thread
cardmagic marked this conversation as resolved.

assert_equal ["Updated second"], duplicates.map(&:title)
Comment thread
cardmagic marked this conversation as resolved.
end
end
16 changes: 16 additions & 0 deletions test/base/enumerable_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,20 @@ def test_latest_handles_missing_dates
assert_equal "Has Date", latest.first[:title]
assert_equal "No Date", latest.last[:title]
end

def test_latest_preserves_invalid_dates_without_failing
feed = SimpleRSS.parse <<~XML
<rss version="2.0">
<channel>
<title>Mixed dates</title>
<item><title>Invalid</title><pubDate>not-a-date</pubDate></item>
<item><title>Valid</title><pubDate>2026-09-01T00:00:00Z</pubDate></item>
</channel>
</rss>
XML

assert_equal %w[Valid Invalid], feed.latest.map(&:title)
assert_equal "not-a-date", feed.first.pubDate
assert_equal %w[Invalid Valid], feed.map(&:title)
end
end
55 changes: 55 additions & 0 deletions test/data/mixed_dates.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<rss version="2.0">
<channel>
<title>Mixed dates</title>
<item>
<title>Missing first</title>
</item>
<item>
<guid>publication</guid>
<title>Publication</title>
<pubDate>2026-09-01T00:00:00Z</pubDate>
<updated>2026-09-05T00:00:00Z</updated>
<published>2026-09-06T00:00:00Z</published>
</item>
<item>
<guid>fractional</guid>
<title>Fractional</title>
<pubDate>2026-09-03T00:00:00.000000001Z</pubDate>
</item>
<item>
<guid>updated-first</guid>
<title>Updated first</title>
<pubDate>not-a-date</pubDate>
<updated>2026-09-03T00:00:00.000000002Z</updated>
<published>2026-09-07T00:00:00Z</published>
</item>
<item>
<guid>published</guid>
<title>Published</title>
<pubDate></pubDate>
<updated>not-a-date</updated>
<published>2026-09-02T00:00:00Z</published>
</item>
<item>
<guid>historical</guid>
<title>Historical</title>
<pubDate>1965-01-01T00:00:00Z</pubDate>
</item>
<item>
<guid>updated-second</guid>
<title>Updated second</title>
<updated>2026-09-03T01:00:00.000000002+01:00</updated>
</item>
<item>
<guid>blank</guid>
<title>Blank</title>
<pubDate> </pubDate>
</item>
<item>
<guid>invalid</guid>
<title>Invalid</title>
<pubDate>not-a-date</pubDate>
<updated>not-a-date</updated>
</item>
</channel>
</rss>
Loading