From 70144c110cc0a352f13f5f17c637928b9230d203 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 08:29:39 +0000 Subject: [PATCH] Preserve pipeline item metadata Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01H8NygEHhT3zGMnBmQF2F6Q --- doc/VERSIONS | 1 + lib/automatic/feed_maker.rb | 73 ++++++++--- spec/lib/automatic/feed_maker_spec.rb | 167 ++++++++++++++++++++++++++ spec/plugins/filter/limit_spec.rb | 32 ++++- 4 files changed, 257 insertions(+), 16 deletions(-) create mode 100644 spec/lib/automatic/feed_maker_spec.rb diff --git a/doc/VERSIONS b/doc/VERSIONS index 54722d2..0be9656 100644 --- a/doc/VERSIONS +++ b/doc/VERSIONS @@ -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) ------------------- diff --git a/lib/automatic/feed_maker.rb b/lib/automatic/feed_maker.rb index 5832d55..d285c54 100644 --- a/lib/automatic/feed_maker.rb +++ b/lib/automatic/feed_maker.rb @@ -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 @@ -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 @@ -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 } @@ -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 diff --git a/spec/lib/automatic/feed_maker_spec.rb b/spec/lib/automatic/feed_maker_spec.rb new file mode 100644 index 0000000..296d517 --- /dev/null +++ b/spec/lib/automatic/feed_maker_spec.rb @@ -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: "

Full body

")] + ) + + rebuilt.items.first.content_encoded.should == "

Full body

" + 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: "

Full body

")] + ) + 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 == "

Full body

" + 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: "

Full body

")] + ) + item = rebuilt.items.first + + item.author.should be_nil + item.comments.should == "https://example.com/a#comments" + item.content_encoded.should == "

Full body

" + 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 diff --git a/spec/plugins/filter/limit_spec.rb b/spec/plugins/filter/limit_spec.rb index ffdedb2..6ab55fd 100644 --- a/spec/plugins/filter/limit_spec.rb +++ b/spec/plugins/filter/limit_spec.rb @@ -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') @@ -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 = '

The article body

' + 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 == '

The article body

' + 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 = '

The article body

' + + 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