automatic 14.12.2 → 26.09

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. checksums.yaml +5 -5
  2. data/README.md +669 -83
  3. data/VERSION +1 -1
  4. data/assets/siteinfo/items_all.json +60300 -52138
  5. data/automatic.gemspec +110 -248
  6. data/bin/automatic +20 -139
  7. data/config/feed2console.yml +10 -4
  8. data/config/feed2markdown.yml +41 -0
  9. data/doc/AI_TUTORIAL.md +542 -0
  10. data/doc/BASIC_DESIGN.md +547 -0
  11. data/doc/COPYING.LESSER +165 -0
  12. data/doc/DEPLOYMENT.md +830 -0
  13. data/doc/LICENSE.md +14 -0
  14. data/doc/PLUGINS.md +2015 -0
  15. data/doc/PLUGIN_DEVELOPMENT.md +86 -0
  16. data/doc/POLICY.md +962 -0
  17. data/doc/QUICKSTART.md +260 -0
  18. data/doc/RELEASING.md +394 -0
  19. data/doc/REQUIREMENTS.md +556 -0
  20. data/doc/VERSIONS +266 -0
  21. data/lib/automatic/cli.rb +270 -0
  22. data/lib/automatic/environment.rb +31 -5
  23. data/lib/automatic/feed_maker.rb +65 -25
  24. data/lib/automatic/feed_parser.rb +51 -35
  25. data/lib/automatic/http.rb +107 -0
  26. data/lib/automatic/log.rb +49 -18
  27. data/lib/automatic/opml.rb +3 -1
  28. data/lib/automatic/pipeline.rb +73 -32
  29. data/lib/automatic/recipe.rb +100 -17
  30. data/lib/automatic/version.rb +14 -1
  31. data/lib/automatic.rb +93 -20
  32. data/plugins/custom_feed/svn_log.rb +73 -32
  33. data/plugins/custom_feed/web.rb +348 -0
  34. data/plugins/filter/absolute_uri.rb +43 -27
  35. data/plugins/filter/accept.rb +38 -45
  36. data/plugins/filter/batch.rb +97 -0
  37. data/plugins/filter/claude.rb +217 -0
  38. data/plugins/filter/clear.rb +12 -8
  39. data/plugins/filter/description_link.rb +66 -51
  40. data/plugins/filter/full_feed.rb +170 -52
  41. data/plugins/filter/gemini.rb +216 -0
  42. data/plugins/filter/github_feed.rb +38 -26
  43. data/plugins/filter/ignore.rb +33 -44
  44. data/plugins/filter/image.rb +36 -25
  45. data/plugins/filter/image_source.rb +75 -52
  46. data/plugins/filter/join.rb +105 -0
  47. data/plugins/filter/kimi.rb +216 -0
  48. data/plugins/filter/limit.rb +57 -0
  49. data/plugins/filter/one.rb +19 -26
  50. data/plugins/filter/open_ai.rb +198 -0
  51. data/plugins/filter/present.rb +75 -0
  52. data/plugins/filter/rand.rb +16 -17
  53. data/plugins/filter/sakura_ai.rb +205 -0
  54. data/plugins/filter/sanitize.rb +29 -34
  55. data/plugins/filter/sort.rb +20 -27
  56. data/plugins/filter/tumblr_resize.rb +31 -23
  57. data/plugins/notify/ikachan.rb +86 -48
  58. data/plugins/provide/fluentd.rb +43 -24
  59. data/plugins/publish/amazon_s3.rb +79 -40
  60. data/plugins/publish/console.rb +19 -16
  61. data/plugins/publish/console_link.rb +20 -17
  62. data/plugins/publish/eject.rb +48 -26
  63. data/plugins/publish/fluentd.rb +50 -30
  64. data/plugins/publish/hatena_bookmark.rb +88 -71
  65. data/plugins/publish/instapaper.rb +69 -59
  66. data/plugins/publish/markdown.rb +278 -0
  67. data/plugins/publish/memcached.rb +35 -29
  68. data/plugins/store/database.rb +50 -48
  69. data/plugins/store/digest.rb +212 -0
  70. data/plugins/store/file.rb +107 -68
  71. data/plugins/store/full_text.rb +31 -28
  72. data/plugins/store/permalink.rb +18 -22
  73. data/plugins/subscription/feed.rb +34 -21
  74. data/plugins/subscription/link.rb +31 -32
  75. data/plugins/subscription/text.rb +40 -45
  76. data/plugins/subscription/tumblr.rb +55 -44
  77. data/plugins/subscription/xml.rb +40 -36
  78. metadata +112 -321
  79. data/Gemfile +0 -38
  80. data/Rakefile +0 -59
  81. data/doc/ChangeLog +0 -303
  82. data/doc/PLUGINS +0 -750
  83. data/doc/PLUGINS.ja +0 -753
  84. data/doc/README +0 -511
  85. data/doc/README.ja +0 -519
  86. data/plugins/filter/google_news.rb +0 -50
  87. data/plugins/publish/google_calendar.rb +0 -84
  88. data/plugins/publish/hipchat.rb +0 -46
  89. data/plugins/publish/pocket.rb +0 -45
  90. data/plugins/publish/twitter.rb +0 -58
  91. data/plugins/subscription/chan_toru.rb +0 -57
  92. data/plugins/subscription/g_guide.rb +0 -57
  93. data/plugins/subscription/pocket.rb +0 -51
  94. data/plugins/subscription/twitter.rb +0 -69
  95. data/plugins/subscription/twitter_search.rb +0 -50
  96. data/plugins/subscription/weather.rb +0 -33
  97. data/script/build +0 -84
  98. data/spec/fixtures/sampleFeeds.tsv +0 -1
  99. data/spec/fixtures/sampleFeeds2.tsv +0 -2
  100. data/spec/fixtures/sampleRecipe.yml +0 -24
  101. data/spec/lib/automatic/log_spec.rb +0 -32
  102. data/spec/lib/automatic/pipeline_spec.rb +0 -68
  103. data/spec/lib/automatic/recipe_spec.rb +0 -40
  104. data/spec/lib/automatic_spec.rb +0 -99
  105. data/spec/plugins/custom_feed/svn_log_spec.rb +0 -31
  106. data/spec/plugins/filter/absolute_uri_spec.rb +0 -61
  107. data/spec/plugins/filter/accept_spec.rb +0 -331
  108. data/spec/plugins/filter/clear_spec.rb +0 -49
  109. data/spec/plugins/filter/description_link_spec.rb +0 -138
  110. data/spec/plugins/filter/full_feed_spec.rb +0 -129
  111. data/spec/plugins/filter/github_feed_spec.rb +0 -55
  112. data/spec/plugins/filter/google_news_spec.rb +0 -69
  113. data/spec/plugins/filter/ignore_spec.rb +0 -328
  114. data/spec/plugins/filter/image_source_spec.rb +0 -89
  115. data/spec/plugins/filter/image_spec.rb +0 -65
  116. data/spec/plugins/filter/one_spec.rb +0 -71
  117. data/spec/plugins/filter/rand_spec.rb +0 -52
  118. data/spec/plugins/filter/sanitize_spec.rb +0 -153
  119. data/spec/plugins/filter/sort_spec.rb +0 -189
  120. data/spec/plugins/filter/tumblr_resize_spec.rb +0 -109
  121. data/spec/plugins/notify/ikachan_spec.rb +0 -58
  122. data/spec/plugins/provide/fluentd_spec.rb +0 -49
  123. data/spec/plugins/publish/amazon_s3_spec.rb +0 -40
  124. data/spec/plugins/publish/console_spec.rb +0 -30
  125. data/spec/plugins/publish/eject_spec.rb +0 -40
  126. data/spec/plugins/publish/fluentd_spec.rb +0 -40
  127. data/spec/plugins/publish/google_calendar_spec.rb +0 -83
  128. data/spec/plugins/publish/hatena_bookmark_spec.rb +0 -134
  129. data/spec/plugins/publish/hipchat_spec.rb +0 -69
  130. data/spec/plugins/publish/instapaper_spec.rb +0 -82
  131. data/spec/plugins/publish/memcached_spec.rb +0 -63
  132. data/spec/plugins/publish/pocket_spec.rb +0 -51
  133. data/spec/plugins/publish/twitter_spec.rb +0 -73
  134. data/spec/plugins/store/file_spec.rb +0 -58
  135. data/spec/plugins/store/full_text_spec.rb +0 -152
  136. data/spec/plugins/store/permalink_spec.rb +0 -206
  137. data/spec/plugins/subscription/chan_toru_spec.rb +0 -56
  138. data/spec/plugins/subscription/feed_spec.rb +0 -71
  139. data/spec/plugins/subscription/g_guide_spec.rb +0 -82
  140. data/spec/plugins/subscription/link_spec.rb +0 -72
  141. data/spec/plugins/subscription/pocket_spec.rb +0 -57
  142. data/spec/plugins/subscription/text_spec.rb +0 -84
  143. data/spec/plugins/subscription/tumblr_spec.rb +0 -74
  144. data/spec/plugins/subscription/twitter_search_spec.rb +0 -57
  145. data/spec/plugins/subscription/twitter_spec.rb +0 -73
  146. data/spec/plugins/subscription/weather_spec.rb +0 -44
  147. data/spec/plugins/subscription/xml_spec.rb +0 -84
  148. data/spec/spec_helper.rb +0 -106
  149. data/spec/user_dir/plugins/store/mock.rb +0 -16
  150. data/test/fixtures/sampleOPML.xml +0 -11
  151. data/test/integration/test_absoluteurl.yml +0 -25
  152. data/test/integration/test_activerecord.yml +0 -24
  153. data/test/integration/test_add_pocket.yml +0 -26
  154. data/test/integration/test_chan_toru.yml +0 -21
  155. data/test/integration/test_descriptionlink.yml +0 -21
  156. data/test/integration/test_fluentd.yml +0 -22
  157. data/test/integration/test_fulltext.yml +0 -30
  158. data/test/integration/test_google_news.yml +0 -21
  159. data/test/integration/test_googlealert.yml +0 -21
  160. data/test/integration/test_hatenabookmark.yml +0 -30
  161. data/test/integration/test_ignore.yml +0 -25
  162. data/test/integration/test_ignore2.yml +0 -22
  163. data/test/integration/test_image2local.yml +0 -33
  164. data/test/integration/test_instapaper.yml +0 -26
  165. data/test/integration/test_link2local.yml +0 -34
  166. data/test/integration/test_one.yml +0 -23
  167. data/test/integration/test_pocket.yml +0 -22
  168. data/test/integration/test_rand.yml +0 -21
  169. data/test/integration/test_sanitize.yml +0 -23
  170. data/test/integration/test_sort.yml +0 -36
  171. data/test/integration/test_svnlog.yml +0 -15
  172. data/test/integration/test_text2feed.yml +0 -36
  173. data/test/integration/test_tumblr2local.yml +0 -43
  174. data/test/integration/test_twitter_search.yml +0 -22
  175. data/test/integration/test_weather.yml +0 -19
  176. data/test/integration/test_xml2fluentd.yml +0 -21
  177. data/vendor/.gitkeep +0 -0
@@ -1,37 +1,120 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Recipe
3
- # Author:: ainame
4
- # 774 <http://id774.net>
5
- # Created:: Feb 18, 2012
6
- # Updated:: Jun 14, 2012
7
- # Copyright:: Copyright (c) 2012-2013 Automatic Ruby Developers.
8
- # License:: Licensed under the GNU GENERAL PUBLIC LICENSE, Version 3.0.
2
+ # Name:: Automatic::Recipe
3
+ # Author: ainame
4
+ # Source Code:: https://github.com/id774/automaticruby
5
+ # License:: The GPL version 3, or LGPL version 3 (Dual License).
6
+ # Contact:: idnanashi@gmail.com
7
+ # Created:: Feb 18, 2012
8
+ # Updated:: Sep 6, 2026
9
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
10
+ #
11
+ # Turns a Recipe file into something the pipeline can iterate. The format is
12
+ # specified in doc/PLUGINS.md section 2.
13
+ #
14
+ # Each plugin entry's documented shape -- a mapping naming a module, with an
15
+ # optional config mapping -- is validated here, at load time, rather than
16
+ # left to surface as whatever internal exception a malformed entry happens to
17
+ # raise once the pipeline reaches it.
9
18
 
19
+ require 'date'
10
20
  require 'hashie'
11
21
  require 'yaml'
12
22
 
13
23
  module Automatic
14
24
  class Recipe
25
+ # A Recipe is trusted local configuration, but it is still loaded safely:
26
+ # the document may name none of its own Ruby classes to instantiate. That
27
+ # costs nothing, since no Recipe needs it. It is a second line of defence
28
+ # and not the trust boundary; see doc/REQUIREMENTS.md section 17.
29
+ PERMITTED_CLASSES = [Date, Time, Symbol].freeze
30
+
15
31
  attr_reader :procedure
16
32
 
17
- def initialize(path = "")
33
+ def initialize(path = '')
18
34
  load_recipe(path)
19
35
  end
20
36
 
21
37
  def load_recipe(path)
22
- dir = File.join((File.expand_path('~/.automatic/config/')), path)
23
- path = dir if File.exist?(dir)
24
- @procedure = Hashie::Mash.new(YAML.load(File.read(path)))
25
- log_level = @procedure.global && @procedure.global.log && @procedure.global.log.level
26
- Automatic::Log.level(log_level)
27
- Automatic::Log.puts("info", "Loading Recipe: #{path}")
38
+ resolved = resolve_path(path)
39
+ document = parse(resolved)
40
+
41
+ unless document.is_a?(Hash)
42
+ raise InvalidRecipeError,
43
+ "recipe #{resolved} is not a mapping"
44
+ end
45
+
46
+ @procedure = Hashie::Mash.new(document)
47
+
48
+ unless @procedure.plugins.is_a?(Array)
49
+ raise InvalidRecipeError,
50
+ "recipe #{resolved} has no plugins sequence"
51
+ end
52
+
53
+ validate_plugins(resolved)
54
+
55
+ Automatic::Log.level(@procedure.global&.log&.level)
56
+ Automatic::Log.puts('info', "Loading Recipe: #{resolved}")
28
57
  @procedure
29
58
  end
30
59
 
31
60
  def each_plugin
32
- @procedure.plugins.each {|plugin|
33
- yield plugin
34
- }
61
+ return enum_for(:each_plugin) unless block_given?
62
+
63
+ @procedure.plugins.each { |plugin| yield plugin }
64
+ end
65
+
66
+ private
67
+
68
+ # A bare filename is looked for in the user's config directory first;
69
+ # anything not found there is used as a path exactly as given.
70
+ def resolve_path(path)
71
+ in_user_dir = File.join(Automatic.user_config_dir, path.to_s)
72
+ File.exist?(in_user_dir) ? in_user_dir : path.to_s
73
+ end
74
+
75
+ def parse(path)
76
+ YAML.safe_load(
77
+ File.read(path),
78
+ permitted_classes: PERMITTED_CLASSES,
79
+ aliases: true
80
+ )
81
+ end
82
+
83
+ # Only the shape doc/PLUGINS.md documents is checked here: a mapping
84
+ # naming a module, with an optional config mapping. What a plugin does
85
+ # with its own config is that plugin's concern, not this one's.
86
+ def validate_plugins(resolved)
87
+ @procedure.plugins.each_with_index do |plugin, index|
88
+ validate_plugin_entry(resolved, plugin, index)
89
+ end
90
+ end
91
+
92
+ def validate_plugin_entry(resolved, plugin, index)
93
+ unless plugin.is_a?(Hash)
94
+ raise InvalidRecipeError,
95
+ "recipe #{resolved} plugins[#{index}] is not a mapping"
96
+ end
97
+
98
+ validate_module_name(resolved, plugin, index)
99
+ validate_plugin_config(resolved, plugin, index)
100
+ end
101
+
102
+ def validate_module_name(resolved, plugin, index)
103
+ name = plugin['module']
104
+
105
+ return if name.is_a?(String) && !name.strip.empty?
106
+
107
+ raise InvalidRecipeError,
108
+ "recipe #{resolved} plugins[#{index}] has no module name"
109
+ end
110
+
111
+ def validate_plugin_config(resolved, plugin, index)
112
+ config = plugin['config']
113
+
114
+ return if config.nil? || config.is_a?(Hash)
115
+
116
+ raise InvalidRecipeError,
117
+ "recipe #{resolved} plugins[#{index}] has a config that is not a mapping"
35
118
  end
36
119
  end
37
120
  end
@@ -1,3 +1,16 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Name:: Automatic::VERSION
3
+ # Author: id774 (More info: https://id774.net)
4
+ # Source Code:: https://github.com/id774/automaticruby
5
+ # License:: The GPL version 3, or LGPL version 3 (Dual License).
6
+ # Contact:: idnanashi@gmail.com
7
+ # Created:: Feb 18, 2012
8
+ # Updated:: Aug 25, 2026
9
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
10
+
1
11
  module Automatic
2
- VERSION = "14.12.2"
12
+ # The release version. The VERSION file at the repository root carries the
13
+ # same number and the gemspec reads it from there; a spec asserts that the
14
+ # two agree. See doc/POLICY.md section 10.
15
+ VERSION = '26.09'
3
16
  end
data/lib/automatic.rb CHANGED
@@ -1,54 +1,127 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Ruby
3
- # Author:: 774 <http://id774.net>
4
- # Created:: Feb 18, 2012
5
- # Updated:: Feb 21, 2014
6
- # Copyright:: Copyright (c) 2012-2014 Automatic Ruby Developers.
7
- # License:: Licensed under the GNU GENERAL PUBLIC LICENSE, Version 3.0.
2
+ # Name:: Automatic::Ruby
3
+ # Author: id774 (More info: https://id774.net)
4
+ # Source Code:: https://github.com/id774/automaticruby
5
+ # License:: The GPL version 3, or LGPL version 3 (Dual License).
6
+ # Contact:: idnanashi@gmail.com
7
+ # Created:: Feb 18, 2012
8
+ # Updated:: Sep 6, 2026
9
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
10
+ #
11
+ # The framework module: the two directories everything else resolves paths
12
+ # against, and the one method that runs a Recipe. See doc/BASIC_DESIGN.md
13
+ # section 4.3.
8
14
 
9
15
  module Automatic
10
16
  require 'automatic/environment'
17
+ require 'automatic/log'
11
18
  require 'automatic/recipe'
12
19
  require 'automatic/pipeline'
13
- require 'automatic/log'
20
+ require 'automatic/http'
14
21
  require 'automatic/feed_parser'
15
22
  require 'automatic/feed_maker'
16
23
  require 'automatic/version'
17
24
 
18
- USER_DIR = "/.automatic"
25
+ # The user directory, relative to the home directory.
26
+ USER_DIR = '/.automatic'
27
+
28
+ # Base class for every failure the framework raises itself, so that a caller
29
+ # can rescue these without rescuing everything.
30
+ class Error < StandardError; end
31
+
32
+ # Pipeline.run was given no Recipe.
33
+ class NoRecipeError < Error; end
34
+
35
+ # A Recipe named a module for which no plugin file could be found.
36
+ class NoPluginError < Error; end
37
+
38
+ # A Recipe parsed, but is not a document this framework can run.
39
+ class InvalidRecipeError < Error; end
40
+
41
+ # Raised by require_optional when, and only when, the exact feature it was
42
+ # asked to require could not be found. A LoadError subtype rather than an
43
+ # Error subtype, so that a caller already rescuing LoadError still catches
44
+ # it; distinct from plain LoadError so that a caller can tell "the optional
45
+ # gem itself is missing" apart from a LoadError raised from inside that
46
+ # gem's own load. See doc/POLICY.md section 9.1.
47
+ class OptionalDependencyError < LoadError; end
19
48
 
20
49
  class << self
21
50
  attr_accessor :root_dir
22
51
 
23
- def run(args = { })
52
+ # Require a gem that only one plugin, or one optional path, needs, and turn
53
+ # its absence into a message naming the gem, what wanted it and how to get
54
+ # it. Nothing here resolves, installs or tracks a dependency: the `require`
55
+ # is the plugin's own, made where the plugin makes it, and this only
56
+ # replaces `cannot load such file -- dalli` with a sentence an operator can
57
+ # act on. See doc/POLICY.md section 9.1 and doc/PLUGINS.md section 3.8.
58
+ #
59
+ # Automatic.require_optional('sanitize', needed_by: 'FilterSanitize')
60
+ #
61
+ # `gem_name` is given where it differs from the path required, as
62
+ # `active_record` does from the `activerecord` gem.
63
+ def require_optional(feature, needed_by:, gem_name: feature)
64
+ require feature
65
+ rescue LoadError => e
66
+ # e.path is the argument require failed to find. When it matches
67
+ # feature, this require itself is what failed, and the gem naming this
68
+ # feature is what is missing. When it does not, the failure happened
69
+ # somewhere inside feature's own load -- a different missing file -- and
70
+ # is not this gem's absence; it is re-raised unconverted.
71
+ raise e unless e.path == feature
72
+
73
+ raise OptionalDependencyError,
74
+ "The `#{gem_name}` gem is not installed. It is needed by #{needed_by}. " \
75
+ "Install it with `gem install #{gem_name}`, or in a source checkout add " \
76
+ 'its group to the bundle; see the optional plugin dependencies in ' \
77
+ "doc/DEPLOYMENT.md. (#{e.message})"
78
+ end
79
+
80
+ # Run one Recipe. root_dir is the installation root; user_dir is honoured
81
+ # only under AUTOMATIC_RUBY_ENV=test, which is how the specs point the
82
+ # plugin loader at a fixture directory.
83
+ def run(args = {})
24
84
  self.root_dir = args[:root_dir]
25
85
  self.user_dir = args[:user_dir]
26
86
  Automatic::Pipeline.run(args[:recipe])
27
87
  end
28
88
 
29
89
  def plugins_dir
30
- File.join(@root_dir, "plugins")
90
+ File.join(@root_dir.to_s, 'plugins')
31
91
  end
32
92
 
33
93
  def config_dir
34
- File.join(@root_dir, "config")
94
+ File.join(@root_dir.to_s, 'config')
35
95
  end
36
96
 
97
+ # Defaults to ~/.automatic when nothing has set it, so that a caller which
98
+ # never went through .run still resolves the user directory.
37
99
  def user_dir
38
- @user_dir
100
+ @user_dir ||= default_user_dir
39
101
  end
40
102
 
41
- def user_dir=(_user_dir)
42
- if ENV["AUTOMATIC_RUBY_ENV"] == "test" && !(_user_dir.nil?)
43
- @user_dir = _user_dir
44
- else
45
- @user_dir = File.expand_path("~/") + USER_DIR
46
- end
103
+ def user_dir=(dir)
104
+ @user_dir =
105
+ if ENV['AUTOMATIC_RUBY_ENV'] == 'test' && !dir.nil?
106
+ dir
107
+ else
108
+ default_user_dir
109
+ end
47
110
  end
48
111
 
49
112
  def user_plugins_dir
50
- File.join(@user_dir, "plugins")
113
+ File.join(user_dir.to_s, 'plugins')
51
114
  end
52
- end
53
115
 
116
+ # Where a Recipe named by a bare filename is looked for.
117
+ def user_config_dir
118
+ File.join(user_dir.to_s, 'config')
119
+ end
120
+
121
+ private
122
+
123
+ def default_user_dir
124
+ File.expand_path('~') + USER_DIR
125
+ end
126
+ end
54
127
  end
@@ -1,55 +1,96 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Plugin::CustomFeed::SVNLog
3
- # Author:: kzgs
4
- # Created:: Feb 29, 2012
5
- # Updated:: Mar 3, 2012
6
- # Copyright:: Copyright (c) 2012-2013 Automatic Ruby Developers.
7
- # License:: Licensed under the GNU GENERAL PUBLIC LICENSE, Version 3.0.
2
+ # Name:: Automatic::Plugin::CustomFeed::SVNLog
3
+ # Author: kzgs
4
+ # Source Code:: https://github.com/id774/automaticruby
5
+ # License:: The GPL version 3, or LGPL version 3 (Dual License).
6
+ # Contact:: idnanashi@gmail.com
7
+ # Created:: Feb 29, 2012
8
+ # Updated:: Aug 15, 2026
9
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
8
10
 
11
+ require 'rexml/document'
9
12
  require 'rss/maker'
13
+ require 'time'
10
14
 
11
15
  module Automatic::Plugin
12
16
  class CustomFeedSVNLog
13
- require 'xmlsimple'
17
+ DEFAULT_FETCH_ITEMS = 30
14
18
 
15
- def initialize(config, pipeline=[])
16
- @config = config
19
+ def initialize(config, pipeline = [])
20
+ @config = config || {}
17
21
  @pipeline = pipeline
18
22
  end
19
23
 
20
24
  def run
21
- revisions = XmlSimple.xml_in(`svn log #{svn_log_argument}`)["logentry"]
22
- @pipeline << RSS::Maker.make("1.0") {|maker|
23
- maker.channel.title = @config["title"] || ""
24
- maker.channel.about = ""
25
- maker.channel.description = ""
26
- maker.channel.link = base_url
27
- revisions.each {|rev|
28
- item = maker.items.new_item
29
- item.title = "#{rev["msg"]} by #{rev["author"]}"
30
- item.link = base_url+"/!svn/bc/#{rev["revision"]}"
31
- item.date = Time.parse(rev["date"][0])
32
- }
33
- }
34
- return @pipeline
25
+ entries = revisions
26
+ if entries.empty?
27
+ # RSS 1.0 has no representation for a channel with no items, and a
28
+ # repository with no revisions in the window asked for is an ordinary
29
+ # answer rather than a failure.
30
+ Automatic::Log.puts('warn', "No revisions returned by svn log for #{base_url}")
31
+ return @pipeline
32
+ end
33
+
34
+ @pipeline << feed(entries)
35
+ @pipeline
35
36
  end
36
37
 
37
38
  private
38
39
 
39
- def base_url
40
- return @config['target'].gsub(/\/$/, "")
40
+ def feed(revisions)
41
+ RSS::Maker.make('1.0') do |maker|
42
+ maker.channel.title = @config['title'].to_s
43
+ maker.channel.about = ''
44
+ maker.channel.description = ''
45
+ maker.channel.link = base_url
46
+
47
+ revisions.each do |revision|
48
+ item = maker.items.new_item
49
+ item.title = "#{revision['msg']} by #{revision['author']}"
50
+ item.link = "#{base_url}/!svn/bc/#{revision['revision']}"
51
+ item.date = Time.parse(revision['date'])
52
+ end
53
+ end
54
+ end
55
+
56
+ # `svn log --xml` as REXML sees it. REXML is a runtime dependency of this
57
+ # framework already -- the OPML parser uses it -- so this plugin needs the
58
+ # svn command and no gem of its own.
59
+ def revisions
60
+ REXML::Document.new(svn_log).elements.to_a('/log/logentry').map do |entry|
61
+ {
62
+ 'revision' => entry.attributes['revision'].to_s,
63
+ 'author' => text(entry, 'author'),
64
+ 'msg' => text(entry, 'msg'),
65
+ 'date' => text(entry, 'date')
66
+ }
67
+ end
41
68
  end
42
69
 
43
- def svn_log_argument
44
- return [
45
- base_url,
46
- "--xml",
47
- "--limit=#{limit}"
48
- ].join(" ")
70
+ def text(entry, name)
71
+ element = entry.elements[name]
72
+ element.nil? ? '' : element.text.to_s
73
+ end
74
+
75
+ # The command is run as an argument vector rather than through a shell, so
76
+ # a repository URL cannot become part of a command line. Point `target` at
77
+ # a repository you control regardless: svn itself will do what the URL
78
+ # tells it to.
79
+ def svn_log
80
+ output = IO.popen(['svn', 'log', base_url, '--xml', "--limit=#{limit}"],
81
+ err: File::NULL, &:read)
82
+ raise "svn log failed for #{base_url}" unless $?.success?
83
+
84
+ output
85
+ end
86
+
87
+ def base_url
88
+ @base_url ||= @config['target'].to_s.sub(%r{/\z}, '')
49
89
  end
50
90
 
51
91
  def limit
52
- return @config['fetch_items'] || 30
92
+ value = @config['fetch_items'].to_i
93
+ value.positive? ? value : DEFAULT_FETCH_ITEMS
53
94
  end
54
95
  end
55
96
  end