automatic 14.12.2 → 26.08

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 (172) hide show
  1. checksums.yaml +5 -5
  2. data/README.md +635 -83
  3. data/VERSION +1 -1
  4. data/automatic.gemspec +109 -248
  5. data/bin/automatic +20 -139
  6. data/config/feed2console.yml +10 -4
  7. data/config/feed2markdown.yml +41 -0
  8. data/doc/AI_TUTORIAL.md +518 -0
  9. data/doc/BASIC_DESIGN.md +516 -0
  10. data/doc/COPYING.LESSER +165 -0
  11. data/doc/DEPLOYMENT.md +824 -0
  12. data/doc/LICENSE.md +14 -0
  13. data/doc/PLUGINS.md +1875 -0
  14. data/doc/PLUGIN_DEVELOPMENT.md +86 -0
  15. data/doc/POLICY.md +857 -0
  16. data/doc/QUICKSTART.md +256 -0
  17. data/doc/RELEASING.md +381 -0
  18. data/doc/REQUIREMENTS.md +526 -0
  19. data/doc/VERSIONS +208 -0
  20. data/lib/automatic/cli.rb +248 -0
  21. data/lib/automatic/environment.rb +31 -5
  22. data/lib/automatic/feed_maker.rb +10 -9
  23. data/lib/automatic/feed_parser.rb +51 -35
  24. data/lib/automatic/http.rb +107 -0
  25. data/lib/automatic/log.rb +49 -18
  26. data/lib/automatic/opml.rb +3 -1
  27. data/lib/automatic/pipeline.rb +63 -32
  28. data/lib/automatic/recipe.rb +56 -17
  29. data/lib/automatic/version.rb +14 -1
  30. data/lib/automatic.rb +78 -20
  31. data/plugins/custom_feed/svn_log.rb +73 -32
  32. data/plugins/custom_feed/web.rb +348 -0
  33. data/plugins/filter/absolute_uri.rb +43 -27
  34. data/plugins/filter/accept.rb +38 -45
  35. data/plugins/filter/claude.rb +217 -0
  36. data/plugins/filter/clear.rb +12 -8
  37. data/plugins/filter/description_link.rb +49 -51
  38. data/plugins/filter/full_feed.rb +158 -52
  39. data/plugins/filter/gemini.rb +216 -0
  40. data/plugins/filter/github_feed.rb +38 -26
  41. data/plugins/filter/ignore.rb +33 -44
  42. data/plugins/filter/image.rb +36 -25
  43. data/plugins/filter/image_source.rb +58 -52
  44. data/plugins/filter/join.rb +107 -0
  45. data/plugins/filter/one.rb +19 -26
  46. data/plugins/filter/open_ai.rb +198 -0
  47. data/plugins/filter/rand.rb +16 -17
  48. data/plugins/filter/sakura_ai.rb +205 -0
  49. data/plugins/filter/sanitize.rb +29 -34
  50. data/plugins/filter/sort.rb +20 -27
  51. data/plugins/filter/tumblr_resize.rb +31 -23
  52. data/plugins/notify/ikachan.rb +86 -48
  53. data/plugins/provide/fluentd.rb +43 -24
  54. data/plugins/publish/amazon_s3.rb +73 -40
  55. data/plugins/publish/console.rb +19 -16
  56. data/plugins/publish/console_link.rb +20 -17
  57. data/plugins/publish/eject.rb +48 -26
  58. data/plugins/publish/fluentd.rb +50 -30
  59. data/plugins/publish/hatena_bookmark.rb +88 -71
  60. data/plugins/publish/instapaper.rb +69 -59
  61. data/plugins/publish/markdown.rb +278 -0
  62. data/plugins/publish/memcached.rb +35 -29
  63. data/plugins/store/database.rb +50 -48
  64. data/plugins/store/digest.rb +212 -0
  65. data/plugins/store/file.rb +99 -68
  66. data/plugins/store/full_text.rb +32 -25
  67. data/plugins/store/permalink.rb +18 -22
  68. data/plugins/subscription/feed.rb +34 -21
  69. data/plugins/subscription/link.rb +31 -32
  70. data/plugins/subscription/text.rb +32 -46
  71. data/plugins/subscription/tumblr.rb +55 -44
  72. data/plugins/subscription/xml.rb +40 -36
  73. metadata +108 -321
  74. data/Gemfile +0 -38
  75. data/Rakefile +0 -59
  76. data/doc/ChangeLog +0 -303
  77. data/doc/PLUGINS +0 -750
  78. data/doc/PLUGINS.ja +0 -753
  79. data/doc/README +0 -511
  80. data/doc/README.ja +0 -519
  81. data/plugins/filter/google_news.rb +0 -50
  82. data/plugins/publish/google_calendar.rb +0 -84
  83. data/plugins/publish/hipchat.rb +0 -46
  84. data/plugins/publish/pocket.rb +0 -45
  85. data/plugins/publish/twitter.rb +0 -58
  86. data/plugins/subscription/chan_toru.rb +0 -57
  87. data/plugins/subscription/g_guide.rb +0 -57
  88. data/plugins/subscription/pocket.rb +0 -51
  89. data/plugins/subscription/twitter.rb +0 -69
  90. data/plugins/subscription/twitter_search.rb +0 -50
  91. data/plugins/subscription/weather.rb +0 -33
  92. data/script/build +0 -84
  93. data/spec/fixtures/sampleFeeds.tsv +0 -1
  94. data/spec/fixtures/sampleFeeds2.tsv +0 -2
  95. data/spec/fixtures/sampleRecipe.yml +0 -24
  96. data/spec/lib/automatic/log_spec.rb +0 -32
  97. data/spec/lib/automatic/pipeline_spec.rb +0 -68
  98. data/spec/lib/automatic/recipe_spec.rb +0 -40
  99. data/spec/lib/automatic_spec.rb +0 -99
  100. data/spec/plugins/custom_feed/svn_log_spec.rb +0 -31
  101. data/spec/plugins/filter/absolute_uri_spec.rb +0 -61
  102. data/spec/plugins/filter/accept_spec.rb +0 -331
  103. data/spec/plugins/filter/clear_spec.rb +0 -49
  104. data/spec/plugins/filter/description_link_spec.rb +0 -138
  105. data/spec/plugins/filter/full_feed_spec.rb +0 -129
  106. data/spec/plugins/filter/github_feed_spec.rb +0 -55
  107. data/spec/plugins/filter/google_news_spec.rb +0 -69
  108. data/spec/plugins/filter/ignore_spec.rb +0 -328
  109. data/spec/plugins/filter/image_source_spec.rb +0 -89
  110. data/spec/plugins/filter/image_spec.rb +0 -65
  111. data/spec/plugins/filter/one_spec.rb +0 -71
  112. data/spec/plugins/filter/rand_spec.rb +0 -52
  113. data/spec/plugins/filter/sanitize_spec.rb +0 -153
  114. data/spec/plugins/filter/sort_spec.rb +0 -189
  115. data/spec/plugins/filter/tumblr_resize_spec.rb +0 -109
  116. data/spec/plugins/notify/ikachan_spec.rb +0 -58
  117. data/spec/plugins/provide/fluentd_spec.rb +0 -49
  118. data/spec/plugins/publish/amazon_s3_spec.rb +0 -40
  119. data/spec/plugins/publish/console_spec.rb +0 -30
  120. data/spec/plugins/publish/eject_spec.rb +0 -40
  121. data/spec/plugins/publish/fluentd_spec.rb +0 -40
  122. data/spec/plugins/publish/google_calendar_spec.rb +0 -83
  123. data/spec/plugins/publish/hatena_bookmark_spec.rb +0 -134
  124. data/spec/plugins/publish/hipchat_spec.rb +0 -69
  125. data/spec/plugins/publish/instapaper_spec.rb +0 -82
  126. data/spec/plugins/publish/memcached_spec.rb +0 -63
  127. data/spec/plugins/publish/pocket_spec.rb +0 -51
  128. data/spec/plugins/publish/twitter_spec.rb +0 -73
  129. data/spec/plugins/store/file_spec.rb +0 -58
  130. data/spec/plugins/store/full_text_spec.rb +0 -152
  131. data/spec/plugins/store/permalink_spec.rb +0 -206
  132. data/spec/plugins/subscription/chan_toru_spec.rb +0 -56
  133. data/spec/plugins/subscription/feed_spec.rb +0 -71
  134. data/spec/plugins/subscription/g_guide_spec.rb +0 -82
  135. data/spec/plugins/subscription/link_spec.rb +0 -72
  136. data/spec/plugins/subscription/pocket_spec.rb +0 -57
  137. data/spec/plugins/subscription/text_spec.rb +0 -84
  138. data/spec/plugins/subscription/tumblr_spec.rb +0 -74
  139. data/spec/plugins/subscription/twitter_search_spec.rb +0 -57
  140. data/spec/plugins/subscription/twitter_spec.rb +0 -73
  141. data/spec/plugins/subscription/weather_spec.rb +0 -44
  142. data/spec/plugins/subscription/xml_spec.rb +0 -84
  143. data/spec/spec_helper.rb +0 -106
  144. data/spec/user_dir/plugins/store/mock.rb +0 -16
  145. data/test/fixtures/sampleOPML.xml +0 -11
  146. data/test/integration/test_absoluteurl.yml +0 -25
  147. data/test/integration/test_activerecord.yml +0 -24
  148. data/test/integration/test_add_pocket.yml +0 -26
  149. data/test/integration/test_chan_toru.yml +0 -21
  150. data/test/integration/test_descriptionlink.yml +0 -21
  151. data/test/integration/test_fluentd.yml +0 -22
  152. data/test/integration/test_fulltext.yml +0 -30
  153. data/test/integration/test_google_news.yml +0 -21
  154. data/test/integration/test_googlealert.yml +0 -21
  155. data/test/integration/test_hatenabookmark.yml +0 -30
  156. data/test/integration/test_ignore.yml +0 -25
  157. data/test/integration/test_ignore2.yml +0 -22
  158. data/test/integration/test_image2local.yml +0 -33
  159. data/test/integration/test_instapaper.yml +0 -26
  160. data/test/integration/test_link2local.yml +0 -34
  161. data/test/integration/test_one.yml +0 -23
  162. data/test/integration/test_pocket.yml +0 -22
  163. data/test/integration/test_rand.yml +0 -21
  164. data/test/integration/test_sanitize.yml +0 -23
  165. data/test/integration/test_sort.yml +0 -36
  166. data/test/integration/test_svnlog.yml +0 -15
  167. data/test/integration/test_text2feed.yml +0 -36
  168. data/test/integration/test_tumblr2local.yml +0 -43
  169. data/test/integration/test_twitter_search.yml +0 -22
  170. data/test/integration/test_weather.yml +0 -19
  171. data/test/integration/test_xml2fluentd.yml +0 -21
  172. data/vendor/.gitkeep +0 -0
@@ -0,0 +1,107 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Name:: Automatic::Http
3
+ # Author: id774 (More info: http://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:: Aug 15, 2026
8
+ # Updated:: Aug 21, 2026
9
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
10
+ #
11
+ # One way in for everything the plugins fetch over HTTP.
12
+ #
13
+ # This exists because seven plugins were each calling `URI.open` on a string,
14
+ # and the four decisions that call implies -- which schemes are allowed, how
15
+ # long to wait, how many redirects to follow, what to send as a User-Agent --
16
+ # were being made seven times, mostly by omission. They are made once here.
17
+ # It is a helper, not a client framework: one method fetches, one builds a URI,
18
+ # and a plugin that wants something else still calls Ruby directly.
19
+ # See doc/POLICY.md section 9.2 and doc/PLUGINS.md section 3.8.
20
+
21
+ require 'open-uri'
22
+ require 'uri'
23
+ require 'automatic/version'
24
+
25
+ module Automatic
26
+ module Http
27
+ # A link in a pipeline item comes from a feed, which is to say from
28
+ # outside. `URI.open` on such a string will happily read `file:///etc/passwd`
29
+ # or run an FTP session; a plugin fetching an article body wants neither.
30
+ SCHEMES = %w[http https].freeze
31
+
32
+ OPEN_TIMEOUT = 10
33
+ READ_TIMEOUT = 30
34
+
35
+ # open-uri follows redirects itself and refuses an HTTPS-to-HTTP downgrade.
36
+ # This bounds the chain so that a redirect loop ends as an error rather
37
+ # than as an unattended run that never returns.
38
+ REDIRECT_LIMIT = 5
39
+
40
+ USER_AGENT = "Automatic Ruby/#{Automatic::VERSION} " \
41
+ '(+https://github.com/id774/automaticruby)'
42
+
43
+ # The RFC 2396 parser is named directly: `URI.escape` was removed in Ruby
44
+ # 3.0, and `URI::Parser` became the RFC 3986 parser in 3.4, which reports
45
+ # #escape as obsolete. This spelling means the same thing on every
46
+ # supported Ruby.
47
+ ESCAPER = URI::RFC2396_Parser.new
48
+
49
+ class << self
50
+ # Fetch a URL and return its body as a string. Raises rather than
51
+ # returning nil: a plugin's retry handling is built around an exception,
52
+ # and a body that could not be fetched is not an empty body.
53
+ def read(url)
54
+ open(url, &:read)
55
+ end
56
+
57
+ # Fetch a URL and yield the IO, for a caller that would rather stream
58
+ # than hold the whole body.
59
+ def open(url, &block)
60
+ uri(url).open(
61
+ 'User-Agent' => USER_AGENT,
62
+ open_timeout: OPEN_TIMEOUT,
63
+ read_timeout: READ_TIMEOUT,
64
+ redirect: true,
65
+ max_redirects: REDIRECT_LIMIT,
66
+ &block
67
+ )
68
+ end
69
+
70
+ # Parse a URL into a URI this framework will fetch, or raise. A string
71
+ # carrying characters a URI may not (a space, a Japanese query term) is
72
+ # escaped and parsed again, which is what the plugins used to do for
73
+ # themselves before every call.
74
+ def uri(url)
75
+ string = url.to_s.strip
76
+ raise ArgumentError, 'no URL to fetch' if string.empty?
77
+
78
+ parsed = parse(string)
79
+ unless SCHEMES.include?(parsed.scheme)
80
+ raise ArgumentError, "not an HTTP or HTTPS URL: #{string}"
81
+ end
82
+ unless parsed.host
83
+ raise ArgumentError, "HTTP or HTTPS URL has no host: #{string}"
84
+ end
85
+
86
+ parsed.normalize
87
+ end
88
+
89
+ # Whether a string is a URL this framework will fetch. For a plugin that
90
+ # skips an item rather than failing the run on one.
91
+ def fetchable?(url)
92
+ uri(url)
93
+ true
94
+ rescue ArgumentError, URI::InvalidURIError
95
+ false
96
+ end
97
+
98
+ private
99
+
100
+ def parse(string)
101
+ URI.parse(string)
102
+ rescue URI::InvalidURIError
103
+ URI.parse(ESCAPER.escape(string))
104
+ end
105
+ end
106
+ end
107
+ end
data/lib/automatic/log.rb CHANGED
@@ -1,31 +1,62 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Log
3
- # Author:: 774 <http://id774.net>
4
- # Created:: Feb 20, 2012
5
- # Updated:: Oct 09, 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::Log
3
+ # Author: id774 (More info: http://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 20, 2012
8
+ # Updated:: Aug 14, 2026
9
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
10
+ #
11
+ # One log, to standard output, behind a level filter set per Recipe by
12
+ # global.log.level. See doc/BASIC_DESIGN.md section 4.7.
8
13
 
9
14
  require 'logger'
10
15
 
11
16
  module Automatic
12
17
  module Log
13
- LOG_LEVELS = ['info', 'warn', 'error', 'none']
18
+ # In increasing order of severity. 'none' is the threshold that admits
19
+ # nothing; it is never used as the level of a message.
20
+ LOG_LEVELS = %w[info warn error none].freeze
14
21
 
15
- def self.level(level)
16
- @level = level
17
- end
22
+ DEFAULT_LEVEL = 'info'
18
23
 
19
- def self.logger
20
- @logger ||= Logger.new(STDOUT)
21
- end
24
+ class << self
25
+ # Set the threshold. An unrecognised name, including nil, means the
26
+ # default: an unattended run should not be silenced by a typo in a
27
+ # Recipe, nor should it raise in the middle of a job.
28
+ def level(level = nil)
29
+ @level = normalize(level, DEFAULT_LEVEL)
30
+ end
22
31
 
23
- def self.puts(level = :info, message)
24
- if LOG_LEVELS.index(@level).to_i > LOG_LEVELS.index(level).to_i
25
- return
32
+ def logger
33
+ @logger ||= Logger.new($stdout)
26
34
  end
27
- logger.send(level, message)
28
- end
29
35
 
36
+ # Replace the logger. Used by the specs; a plugin has no business
37
+ # calling this.
38
+ attr_writer :logger
39
+
40
+ # Emit a message at the given level, if the threshold admits it.
41
+ # Both 'info' and :info are accepted, because plugins pass both.
42
+ def puts(level, message)
43
+ name = normalize(level, DEFAULT_LEVEL)
44
+ return if name == 'none'
45
+ return if LOG_LEVELS.index(name) < LOG_LEVELS.index(current_level)
46
+
47
+ logger.public_send(name, message)
48
+ end
49
+
50
+ private
51
+
52
+ def current_level
53
+ @level ||= DEFAULT_LEVEL
54
+ end
55
+
56
+ def normalize(level, fallback)
57
+ name = level.to_s
58
+ LOG_LEVELS.include?(name) ? name : fallback
59
+ end
60
+ end
30
61
  end
31
62
  end
@@ -220,7 +220,9 @@ module Automatic
220
220
  end # each_outline
221
221
 
222
222
  def read_text
223
- text = ""
223
+ # +'' rather than "": this string is appended to below, and a literal
224
+ # is on its way to being frozen.
225
+ text = +''
224
226
  while event = @p.pull
225
227
  case event.event_type
226
228
  when :end_element
@@ -1,43 +1,74 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Pipeline
3
- # Author:: 774 <http://id774.net>
4
- # Created:: Feb 22, 2012
5
- # Updated:: Mar 16, 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::Pipeline
3
+ # Author: id774 (More info: http://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 22, 2012
8
+ # Updated:: Aug 14, 2026
9
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
10
+ #
11
+ # The core: resolve a plugin's class name to a file, then run the Recipe's
12
+ # plugins in order, each receiving the previous one's output. See
13
+ # doc/BASIC_DESIGN.md section 4.6 and doc/PLUGINS.md section 3.
8
14
 
9
- require 'active_support'
10
- require 'active_support/core_ext'
11
- require 'active_support/deprecation'
15
+ require 'active_support/core_ext/string/inflections'
12
16
 
13
17
  module Automatic
14
- module Plugin end
15
- class NoPluginError < StandardError; end
16
- class NoRecipeError < StandardError; end
18
+ # The namespace every plugin class lives in. Plugin files are registered
19
+ # here with autoload, so a file is read when its constant is first used.
20
+ module Plugin; end
17
21
 
18
22
  module Pipeline
19
- def self.load_plugin(module_name)
20
- Dir[Automatic.user_plugins_dir + "/*",
21
- Automatic.plugins_dir + "/*"].each {|dir|
22
- subdir = File.basename dir
23
- if /#{subdir}_(.*)$/ =~ module_name.underscore
24
- path = dir + "/#{$1}.rb"
25
- return Automatic::Plugin.autoload module_name.to_sym,
26
- path.to_s if File.exists? path
23
+ class << self
24
+ # Resolve a module name such as SubscriptionFeed to
25
+ # <search root>/subscription/feed.rb and register it for autoloading.
26
+ #
27
+ # The user directory is searched before the installation, so a plugin in
28
+ # ~/.automatic/plugins shadows a shipped plugin of the same name.
29
+ def load_plugin(module_name)
30
+ underscored = module_name.underscore
31
+
32
+ search_roots.each do |dir|
33
+ category = File.basename(dir)
34
+ next unless /\A#{Regexp.escape(category)}_(.*)\z/ =~ underscored
35
+
36
+ path = File.join(dir, "#{Regexp.last_match(1)}.rb")
37
+ next unless File.exist?(path)
38
+
39
+ return Automatic::Plugin.autoload(module_name.to_sym, path)
27
40
  end
28
- }
29
- raise NoPluginError, "unknown plugin named #{module_name}"
30
- end
31
41
 
32
- def self.run(recipe)
33
- raise NoRecipeError if recipe.nil?
34
- pipeline = []
35
- recipe.each_plugin {|plugin|
36
- mod = plugin.module
37
- load_plugin(mod)
38
- klass = Automatic::Plugin.const_get(mod)
39
- pipeline = klass.new(plugin.config, pipeline).run
40
- }
42
+ raise NoPluginError, "unknown plugin named #{module_name}"
43
+ end
44
+
45
+ # Run a Recipe: each plugin's return value is the next plugin's input.
46
+ #
47
+ # Nothing here rescues a plugin's exception. A Recipe is a sequence in
48
+ # which each step consumes the previous one's output, so continuing past
49
+ # a failed step would run the rest on a value their author never
50
+ # intended. See doc/REQUIREMENTS.md section 12.
51
+ def run(recipe)
52
+ raise NoRecipeError, 'no recipe given' if recipe.nil?
53
+
54
+ pipeline = []
55
+ recipe.each_plugin do |plugin|
56
+ mod = plugin.module
57
+ load_plugin(mod)
58
+ klass = Automatic::Plugin.const_get(mod)
59
+ pipeline = klass.new(plugin.config, pipeline).run
60
+ end
61
+ pipeline
62
+ end
63
+
64
+ private
65
+
66
+ def search_roots
67
+ Dir[File.join(Automatic.user_plugins_dir, '*'),
68
+ File.join(Automatic.plugins_dir, '*')].select { |path|
69
+ File.directory?(path)
70
+ }
71
+ end
41
72
  end
42
73
  end
43
74
  end
@@ -1,37 +1,76 @@
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:: Aug 14, 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.
9
13
 
14
+ require 'date'
10
15
  require 'hashie'
11
16
  require 'yaml'
12
17
 
13
18
  module Automatic
14
19
  class Recipe
20
+ # A Recipe is trusted local configuration, but it is still loaded safely:
21
+ # the document may name none of its own Ruby classes to instantiate. That
22
+ # costs nothing, since no Recipe needs it. It is a second line of defence
23
+ # and not the trust boundary; see doc/REQUIREMENTS.md section 17.
24
+ PERMITTED_CLASSES = [Date, Time, Symbol].freeze
25
+
15
26
  attr_reader :procedure
16
27
 
17
- def initialize(path = "")
28
+ def initialize(path = '')
18
29
  load_recipe(path)
19
30
  end
20
31
 
21
32
  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}")
33
+ resolved = resolve_path(path)
34
+ document = parse(resolved)
35
+
36
+ unless document.is_a?(Hash)
37
+ raise InvalidRecipeError,
38
+ "recipe #{resolved} is not a mapping"
39
+ end
40
+
41
+ @procedure = Hashie::Mash.new(document)
42
+
43
+ unless @procedure.plugins.is_a?(Array)
44
+ raise InvalidRecipeError,
45
+ "recipe #{resolved} has no plugins sequence"
46
+ end
47
+
48
+ Automatic::Log.level(@procedure.global&.log&.level)
49
+ Automatic::Log.puts('info', "Loading Recipe: #{resolved}")
28
50
  @procedure
29
51
  end
30
52
 
31
53
  def each_plugin
32
- @procedure.plugins.each {|plugin|
33
- yield plugin
34
- }
54
+ return enum_for(:each_plugin) unless block_given?
55
+
56
+ @procedure.plugins.each { |plugin| yield plugin }
57
+ end
58
+
59
+ private
60
+
61
+ # A bare filename is looked for in the user's config directory first;
62
+ # anything not found there is used as a path exactly as given.
63
+ def resolve_path(path)
64
+ in_user_dir = File.join(Automatic.user_config_dir, path.to_s)
65
+ File.exist?(in_user_dir) ? in_user_dir : path.to_s
66
+ end
67
+
68
+ def parse(path)
69
+ YAML.safe_load(
70
+ File.read(path),
71
+ permitted_classes: PERMITTED_CLASSES,
72
+ aliases: true
73
+ )
35
74
  end
36
75
  end
37
76
  end
@@ -1,3 +1,16 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Name:: Automatic::VERSION
3
+ # Author: id774 (More info: http://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 14, 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.08'
3
16
  end
data/lib/automatic.rb CHANGED
@@ -1,54 +1,112 @@
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: http://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 14, 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
19
40
 
20
41
  class << self
21
42
  attr_accessor :root_dir
22
43
 
23
- def run(args = { })
44
+ # Require a gem that only one plugin, or one optional path, needs, and turn
45
+ # its absence into a message naming the gem, what wanted it and how to get
46
+ # it. Nothing here resolves, installs or tracks a dependency: the `require`
47
+ # is the plugin's own, made where the plugin makes it, and this only
48
+ # replaces `cannot load such file -- dalli` with a sentence an operator can
49
+ # act on. See doc/POLICY.md section 9.1 and doc/PLUGINS.md section 3.8.
50
+ #
51
+ # Automatic.require_optional('sanitize', needed_by: 'FilterSanitize')
52
+ #
53
+ # `gem_name` is given where it differs from the path required, as
54
+ # `active_record` does from the `activerecord` gem.
55
+ def require_optional(feature, needed_by:, gem_name: feature)
56
+ require feature
57
+ rescue LoadError => e
58
+ raise LoadError,
59
+ "The `#{gem_name}` gem is not installed. It is needed by #{needed_by}. " \
60
+ "Install it with `gem install #{gem_name}`, or in a source checkout add " \
61
+ 'its group to the bundle; see the optional plugin dependencies in ' \
62
+ "doc/DEPLOYMENT.md. (#{e.message})"
63
+ end
64
+
65
+ # Run one Recipe. root_dir is the installation root; user_dir is honoured
66
+ # only under AUTOMATIC_RUBY_ENV=test, which is how the specs point the
67
+ # plugin loader at a fixture directory.
68
+ def run(args = {})
24
69
  self.root_dir = args[:root_dir]
25
70
  self.user_dir = args[:user_dir]
26
71
  Automatic::Pipeline.run(args[:recipe])
27
72
  end
28
73
 
29
74
  def plugins_dir
30
- File.join(@root_dir, "plugins")
75
+ File.join(@root_dir.to_s, 'plugins')
31
76
  end
32
77
 
33
78
  def config_dir
34
- File.join(@root_dir, "config")
79
+ File.join(@root_dir.to_s, 'config')
35
80
  end
36
81
 
82
+ # Defaults to ~/.automatic when nothing has set it, so that a caller which
83
+ # never went through .run still resolves the user directory.
37
84
  def user_dir
38
- @user_dir
85
+ @user_dir ||= default_user_dir
39
86
  end
40
87
 
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
88
+ def user_dir=(dir)
89
+ @user_dir =
90
+ if ENV['AUTOMATIC_RUBY_ENV'] == 'test' && !dir.nil?
91
+ dir
92
+ else
93
+ default_user_dir
94
+ end
47
95
  end
48
96
 
49
97
  def user_plugins_dir
50
- File.join(@user_dir, "plugins")
98
+ File.join(user_dir.to_s, 'plugins')
99
+ end
100
+
101
+ # Where a Recipe named by a bare filename is looked for.
102
+ def user_config_dir
103
+ File.join(user_dir.to_s, 'config')
51
104
  end
52
- end
53
105
 
106
+ private
107
+
108
+ def default_user_dir
109
+ File.expand_path('~') + USER_DIR
110
+ end
111
+ end
54
112
  end