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
1 change: 1 addition & 0 deletions doc/VERSIONS
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ v26.09 (Release Date: TBD)
- Make doc/PLUGINS.md section 6 the single source of truth for the current plugin catalogue.
- Fix CLI contract drift so inspect parses the first discovered feed and scaffold restores missing bundled siteinfo and example configuration without overwriting existing user data.
- Distinguish missing optional dependencies from unrelated load failures so CLI diagnostics and plugin-spec skips do not hide broken loads.
- Preserve standard item metadata when FeedMaker rebuilds pipelines so filters do not discard source, enclosure or full content.

v26.08 (2026-08-22)
-------------------
Expand Down
73 changes: 58 additions & 15 deletions lib/automatic/feed_maker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# License:: The GPL version 3, or LGPL version 3 (Dual License).
# Contact:: idnanashi@gmail.com
# Created:: Feb 21, 2014
# Updated:: Aug 14, 2026
# Updated:: Sep 6, 2026
# Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.

module Automatic
Expand Down Expand Up @@ -34,6 +34,20 @@ def self.generate_feed(feed)
feed_object
end

# Plain-value standard fields, copied onto the rebuilt item as-is when the
# item being rebuilt carries them. See doc/REQUIREMENTS.md and
# doc/PLUGINS.md for the standard item field contract this preserves.
REBUILD_SIMPLE_FIELDS = %i[description author comments content_encoded].freeze

# source and enclosure are RSS child elements. RSS::Maker exposes each of
# them on a new item as a builder with its own sub-attributes rather than
# accepting the finished element directly, so each is rebuilt attribute by
# attribute instead of by one assignment.
REBUILD_STRUCTURED_FIELDS = {
source: %i[url content],
enclosure: %i[url length type]
}.freeze

def self.create_pipeline(feeds = [])
RSS::Maker.make("2.0") {|maker|
xss = maker.xml_stylesheets.new_xml_stylesheet
Expand All @@ -44,20 +58,16 @@ def self.create_pipeline(feeds = [])

unless feeds.nil?
feeds.each {|feed|
unless feed.link.nil?
Automatic::Log.puts("info", "Create Pipeline: #{feed.link}")
item = maker.items.new_item
item.title = feed.title
item.link = feed.link
begin
item.description = feed.description
item.author = feed.author
item.comments = feed.comments
item.date = feed.pubDate || Time.now
rescue NoMethodError
Automatic::Log.puts("warn", "Undefined field detected in feed.")
end
end
next if feed.link.nil?

Automatic::Log.puts("info", "Create Pipeline: #{feed.link}")
item = maker.items.new_item
item.title = feed.title
item.link = feed.link
item.date = (feed.pubDate if feed.respond_to?(:pubDate)) || Time.now

REBUILD_SIMPLE_FIELDS.each { |field| copy_rebuild_field(feed, item, field) }
REBUILD_STRUCTURED_FIELDS.each_key { |field| copy_rebuild_structured_field(feed, item, field) }
}
end
}
Expand All @@ -77,5 +87,38 @@ def self.content_provide(url, data)
item.date = Time.now
}
end

# A field the item being rebuilt does not carry, or carries as nil, is
# left off the new item: that is the normal shape of an optional field,
# not a reason to skip copying anything else. See doc/POLICY.md on
# distinguishing a missing optional value from a programming error.
def self.copy_rebuild_field(feed, item, field)
return unless feed.respond_to?(field)

value = feed.public_send(field)
return if value.nil?

item.public_send("#{field}=", value)
end
private_class_method :copy_rebuild_field

# source and enclosure are rebuilt sub-attribute by sub-attribute, on the
# same missing-is-normal basis as copy_rebuild_field, so that (for
# example) an enclosure with no type recorded still keeps its url.
def self.copy_rebuild_structured_field(feed, item, field)
return unless feed.respond_to?(field)

value = feed.public_send(field)
return if value.nil?

target = item.public_send(field)
REBUILD_STRUCTURED_FIELDS.fetch(field).each do |sub_field|
next unless value.respond_to?(sub_field)

sub_value = value.public_send(sub_field)
target.public_send("#{sub_field}=", sub_value) unless sub_value.nil?
end
end
private_class_method :copy_rebuild_structured_field
end
end
167 changes: 167 additions & 0 deletions spec/lib/automatic/feed_maker_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# -*- coding: utf-8 -*-
# Name:: Automatic::FeedMaker
# Author: id774 (More info: http://id774.net)
# Source Code:: https://github.com/id774/automaticruby
# License:: The GPL version 3, or LGPL version 3 (Dual License).
# Contact:: idnanashi@gmail.com
# Created:: Sep 6, 2026
# Updated:: Sep 6, 2026
# Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
#
# create_pipeline rebuilds each item it is given into a new RSS feed. This is
# the direct regression test for that rebuild keeping the standard item field
# contract doc/REQUIREMENTS.md and doc/PLUGINS.md describe, rather than
# quietly dropping source, enclosure or content_encoded on the way through, as
# it used to.

require File.expand_path(File.join(File.dirname(__FILE__), '../../spec_helper'))

require 'automatic/feed_maker'

describe Automatic::FeedMaker do
describe ".create_pipeline" do
# Builds a pipeline item the way FeedParser or a previous create_pipeline
# call would hand one on: a real RSS::Rss::Channel::Item, with real
# Source/Enclosure objects where one is given, and every field left unset
# (rather than set to an empty placeholder) where it is not.
def build_item(link: "https://example.com/a", title: "A title",
description: "A description", author: "jdoe",
comments: "https://example.com/a#comments",
date: Time.parse("2020-01-01T00:00:00Z"),
source: nil, enclosure: nil, content_encoded: nil)
item = RSS::Rss::Channel::Item.new
item.link = link
item.title = title
item.description = description unless description.nil?
item.author = author unless author.nil?
item.comments = comments unless comments.nil?
item.pubDate = date unless date.nil?
item.source = source unless source.nil?
item.enclosure = enclosure unless enclosure.nil?
item.content_encoded = content_encoded unless content_encoded.nil?
item
end

let(:source) {
RSS::Rss::Channel::Item::Source.new("https://example.com/feed.xml", "Example Feed")
}

let(:enclosure) {
RSS::Rss::Channel::Item::Enclosure.new("https://example.com/a.mp3", 123, "audio/mpeg")
}

it "preserves title, link, description, author and comments" do
rebuilt = Automatic::FeedMaker.create_pipeline([build_item])
item = rebuilt.items.first

item.title.should == "A title"
item.link.should == "https://example.com/a"
item.description.should == "A description"
item.author.should == "jdoe"
item.comments.should == "https://example.com/a#comments"
end

it "preserves the existing date rather than replacing it with the current time" do
old_date = Time.parse("2001-02-03T04:05:06Z")
rebuilt = Automatic::FeedMaker.create_pipeline([build_item(date: old_date)])

rebuilt.items.first.date.should == old_date
end

it "preserves source" do
rebuilt = Automatic::FeedMaker.create_pipeline([build_item(source: source)])
item = rebuilt.items.first

item.source.should_not be_nil
item.source.url.should == "https://example.com/feed.xml"
item.source.content.should == "Example Feed"
end

it "preserves enclosure" do
rebuilt = Automatic::FeedMaker.create_pipeline([build_item(enclosure: enclosure)])
item = rebuilt.items.first

item.enclosure.should_not be_nil
item.enclosure.url.should == "https://example.com/a.mp3"
item.enclosure.length.should == 123
item.enclosure.type.should == "audio/mpeg"
end

it "preserves content_encoded" do
rebuilt = Automatic::FeedMaker.create_pipeline(
[build_item(content_encoded: "<p>Full body</p>")]
)

rebuilt.items.first.content_encoded.should == "<p>Full body</p>"
end

it "preserves source, enclosure and content_encoded together on the same item" do
rebuilt = Automatic::FeedMaker.create_pipeline(
[build_item(source: source, enclosure: enclosure, content_encoded: "<p>Full body</p>")]
)
item = rebuilt.items.first

item.source.url.should == "https://example.com/feed.xml"
item.enclosure.url.should == "https://example.com/a.mp3"
item.content_encoded.should == "<p>Full body</p>"
end

it "does not fabricate a source, enclosure or content_encoded for an item that had none" do
rebuilt = Automatic::FeedMaker.create_pipeline([build_item])
item = rebuilt.items.first

item.source.should be_nil
item.enclosure.should be_nil
item.content_encoded.should be_nil
end

it "still preserves the other fields when source is absent" do
rebuilt = Automatic::FeedMaker.create_pipeline([build_item(source: nil)])
item = rebuilt.items.first

item.title.should == "A title"
item.description.should == "A description"
end

it "still preserves the other fields when enclosure is absent" do
rebuilt = Automatic::FeedMaker.create_pipeline([build_item(enclosure: nil)])
item = rebuilt.items.first

item.title.should == "A title"
item.author.should == "jdoe"
end

it "still preserves the other fields when content_encoded is absent" do
rebuilt = Automatic::FeedMaker.create_pipeline([build_item(content_encoded: nil)])
item = rebuilt.items.first

item.title.should == "A title"
item.comments.should == "https://example.com/a#comments"
end

# The bug this fixes: one field the item does not carry used to abort
# copying every field after it, because they were all attempted inside one
# begin/rescue NoMethodError block.
it "does not let a missing author interrupt copying the fields declared after it" do
rebuilt = Automatic::FeedMaker.create_pipeline(
[build_item(author: nil, comments: "https://example.com/a#comments",
content_encoded: "<p>Full body</p>")]
)
item = rebuilt.items.first

item.author.should be_nil
item.comments.should == "https://example.com/a#comments"
item.content_encoded.should == "<p>Full body</p>"
end

it "keeps the existing item-count behaviour, skipping an item with no link" do
linked = build_item(link: "https://example.com/a")
unlinked = build_item(link: nil)

rebuilt = Automatic::FeedMaker.create_pipeline([linked, unlinked])

rebuilt.items.size.should == 1
rebuilt.items.first.link.should == "https://example.com/a"
end
end
end
32 changes: 31 additions & 1 deletion spec/plugins/filter/limit_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# License:: The GPL version 3, or LGPL version 3 (Dual License).
# Contact:: idnanashi@gmail.com
# Created:: Aug 24, 2026
# Updated:: Aug 24, 2026
# Updated:: Sep 6, 2026
# Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.

require File.expand_path(File.dirname(__FILE__) + '../../../spec_helper')
Expand Down Expand Up @@ -67,4 +67,34 @@ def limit(config, pipeline)
should raise_error(ArgumentError, 'FilterLimit needs max_items to be a positive integer')
end
end

# FilterLimit selects items by passing survivors through
# Automatic::FeedMaker.create_pipeline, same as every other filter built on
# it; this is an integration regression for that shared helper, not for
# FilterLimit's own selection logic, which the specs above already cover.
describe 'metadata preservation' do
it 'keeps content_encoded, source and enclosure on an item that survives the limit' do
item = pipeline[0].items[0]
item.content_encoded = '<p>The article body</p>'
item.source = RSS::Rss::Channel::Item::Source.new('https://example.com/feed.xml', 'Example Feed')
item.enclosure = RSS::Rss::Channel::Item::Enclosure.new('https://example.com/a.mp3', 123, 'audio/mpeg')

returned = limit({ 'max_items' => 1 }, pipeline)
survivor = returned[0].items[0]

survivor.link.should == 'https://example.com/a'
survivor.content_encoded.should == '<p>The article body</p>'
survivor.source.url.should == 'https://example.com/feed.xml'
survivor.enclosure.url.should == 'https://example.com/a.mp3'
end

it 'still limits to the configured count and keeps item ordering' do
pipeline[0].items[0].content_encoded = '<p>The article body</p>'

returned = limit({ 'max_items' => 3 }, pipeline)

links = returned.flat_map { |feeds| feeds.items.map(&:link) }
links.should == %w[https://example.com/a https://example.com/b https://example.com/c]
end
end
end
Loading