source_monitor 0.7.0 → 0.8.0

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 (141) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/release.md +45 -22
  3. data/.claude/skills/sm-configure/SKILL.md +10 -1
  4. data/.claude/skills/sm-configure/reference/configuration-reference.md +44 -0
  5. data/.claude/skills/sm-host-setup/reference/initializer-template.md +17 -0
  6. data/.claude/skills/sm-host-setup/reference/setup-checklist.md +2 -0
  7. data/.claude/skills/sm-job/reference/job-conventions.md +26 -0
  8. data/.claude/skills/sm-upgrade/reference/version-history.md +22 -0
  9. data/.gitignore +10 -0
  10. data/AGENTS.md +1 -1
  11. data/CHANGELOG.md +56 -0
  12. data/CLAUDE.md +11 -5
  13. data/Gemfile.lock +1 -1
  14. data/README.md +6 -4
  15. data/VERSION +1 -1
  16. data/app/assets/builds/source_monitor/application.css +43 -0
  17. data/app/assets/builds/source_monitor/application.js +127 -0
  18. data/app/assets/builds/source_monitor/application.js.map +3 -3
  19. data/app/assets/javascripts/source_monitor/application.js +2 -0
  20. data/app/assets/javascripts/source_monitor/controllers/notification_container_controller.js +138 -0
  21. data/app/assets/javascripts/source_monitor/controllers/notification_controller.js +11 -0
  22. data/app/controllers/source_monitor/source_favicon_fetches_controller.rb +38 -0
  23. data/app/controllers/source_monitor/sources_controller.rb +11 -0
  24. data/app/helpers/source_monitor/application_helper.rb +51 -0
  25. data/app/jobs/source_monitor/favicon_fetch_job.rb +71 -0
  26. data/app/jobs/source_monitor/import_opml_job.rb +9 -0
  27. data/app/jobs/source_monitor/source_health_check_job.rb +10 -0
  28. data/app/models/source_monitor/source.rb +2 -0
  29. data/app/views/layouts/source_monitor/application.html.erb +23 -2
  30. data/app/views/source_monitor/shared/_toast.html.erb +1 -0
  31. data/app/views/source_monitor/sources/_details.html.erb +34 -5
  32. data/app/views/source_monitor/sources/_row.html.erb +11 -6
  33. data/config/routes.rb +1 -0
  34. data/docs/configuration.md +1 -1
  35. data/docs/upgrade.md +22 -0
  36. data/lib/generators/source_monitor/install/templates/source_monitor.rb.tt +15 -1
  37. data/lib/source_monitor/configuration/favicons_settings.rb +42 -0
  38. data/lib/source_monitor/configuration/http_settings.rb +1 -1
  39. data/lib/source_monitor/configuration/scraping_settings.rb +1 -1
  40. data/lib/source_monitor/configuration.rb +3 -1
  41. data/lib/source_monitor/favicons/discoverer.rb +196 -0
  42. data/lib/source_monitor/fetching/feed_fetcher/source_updater.rb +21 -0
  43. data/lib/source_monitor/fetching/feed_fetcher.rb +1 -0
  44. data/lib/source_monitor/http.rb +5 -3
  45. data/lib/source_monitor/version.rb +1 -1
  46. data/lib/source_monitor.rb +4 -0
  47. data/lib/tasks/test_fast.rake +11 -0
  48. data/source_monitor.gemspec +1 -1
  49. metadata +7 -93
  50. data/.vbw-planning/PROJECT.md +0 -51
  51. data/.vbw-planning/ROADMAP.md +0 -32
  52. data/.vbw-planning/SHIPPED.md +0 -63
  53. data/.vbw-planning/STATE.md +0 -27
  54. data/.vbw-planning/codebase/ARCHITECTURE.md +0 -147
  55. data/.vbw-planning/codebase/CONCERNS.md +0 -99
  56. data/.vbw-planning/codebase/CONVENTIONS.md +0 -97
  57. data/.vbw-planning/codebase/DEPENDENCIES.md +0 -100
  58. data/.vbw-planning/codebase/INDEX.md +0 -86
  59. data/.vbw-planning/codebase/META.md +0 -42
  60. data/.vbw-planning/codebase/PATTERNS.md +0 -262
  61. data/.vbw-planning/codebase/STACK.md +0 -101
  62. data/.vbw-planning/codebase/STRUCTURE.md +0 -324
  63. data/.vbw-planning/codebase/TESTING.md +0 -154
  64. data/.vbw-planning/config.json +0 -53
  65. data/.vbw-planning/discovery.json +0 -26
  66. data/.vbw-planning/milestones/default/ROADMAP.md +0 -115
  67. data/.vbw-planning/milestones/default/STATE.md +0 -82
  68. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01-SUMMARY.md +0 -56
  69. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01.md +0 -187
  70. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02-SUMMARY.md +0 -64
  71. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02.md +0 -137
  72. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01-SUMMARY.md +0 -67
  73. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01.md +0 -142
  74. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02-SUMMARY.md +0 -64
  75. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02.md +0 -138
  76. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03-SUMMARY.md +0 -85
  77. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03.md +0 -147
  78. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04-SUMMARY.md +0 -63
  79. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04.md +0 -129
  80. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05-SUMMARY.md +0 -74
  81. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05.md +0 -154
  82. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION-wave1.md +0 -303
  83. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION.md +0 -510
  84. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01-SUMMARY.md +0 -61
  85. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01.md +0 -161
  86. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02-SUMMARY.md +0 -66
  87. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02.md +0 -132
  88. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03-SUMMARY.md +0 -59
  89. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03.md +0 -171
  90. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04-SUMMARY.md +0 -56
  91. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04.md +0 -152
  92. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/04-CONTEXT.md +0 -33
  93. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01-SUMMARY.md +0 -42
  94. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01.md +0 -119
  95. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02-SUMMARY.md +0 -52
  96. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02.md +0 -195
  97. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03-SUMMARY.md +0 -79
  98. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03.md +0 -130
  99. data/.vbw-planning/milestones/generator-enhancements/REQUIREMENTS.md +0 -72
  100. data/.vbw-planning/milestones/generator-enhancements/ROADMAP.md +0 -125
  101. data/.vbw-planning/milestones/generator-enhancements/SHIPPED.md +0 -40
  102. data/.vbw-planning/milestones/generator-enhancements/STATE.md +0 -43
  103. data/.vbw-planning/milestones/generator-enhancements/phases/01-generator-steps/01-CONTEXT.md +0 -33
  104. data/.vbw-planning/milestones/generator-enhancements/phases/01-generator-steps/01-VERIFICATION.md +0 -86
  105. data/.vbw-planning/milestones/generator-enhancements/phases/01-generator-steps/PLAN-01-SUMMARY.md +0 -61
  106. data/.vbw-planning/milestones/generator-enhancements/phases/01-generator-steps/PLAN-01.md +0 -380
  107. data/.vbw-planning/milestones/generator-enhancements/phases/02-verification/02-VERIFICATION.md +0 -78
  108. data/.vbw-planning/milestones/generator-enhancements/phases/02-verification/PLAN-01-SUMMARY.md +0 -46
  109. data/.vbw-planning/milestones/generator-enhancements/phases/02-verification/PLAN-01.md +0 -500
  110. data/.vbw-planning/milestones/generator-enhancements/phases/03-docs-alignment/03-VERIFICATION.md +0 -89
  111. data/.vbw-planning/milestones/generator-enhancements/phases/03-docs-alignment/PLAN-01-SUMMARY.md +0 -48
  112. data/.vbw-planning/milestones/generator-enhancements/phases/03-docs-alignment/PLAN-01.md +0 -456
  113. data/.vbw-planning/milestones/generator-enhancements/phases/04-dashboard-ux/04-VERIFICATION.md +0 -129
  114. data/.vbw-planning/milestones/generator-enhancements/phases/04-dashboard-ux/PLAN-01-SUMMARY.md +0 -70
  115. data/.vbw-planning/milestones/generator-enhancements/phases/04-dashboard-ux/PLAN-01.md +0 -747
  116. data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/05-VERIFICATION.md +0 -156
  117. data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/PLAN-01-SUMMARY.md +0 -69
  118. data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/PLAN-01.md +0 -455
  119. data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/PLAN-02-SUMMARY.md +0 -39
  120. data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/PLAN-02.md +0 -488
  121. data/.vbw-planning/milestones/generator-enhancements/phases/06-netflix-feed-fix/06-VERIFICATION.md +0 -100
  122. data/.vbw-planning/milestones/generator-enhancements/phases/06-netflix-feed-fix/PLAN-01-SUMMARY.md +0 -37
  123. data/.vbw-planning/milestones/generator-enhancements/phases/06-netflix-feed-fix/PLAN-01.md +0 -345
  124. data/.vbw-planning/milestones/upgrade-assurance/REQUIREMENTS.md +0 -80
  125. data/.vbw-planning/milestones/upgrade-assurance/ROADMAP.md +0 -75
  126. data/.vbw-planning/milestones/upgrade-assurance/STATE.md +0 -29
  127. data/.vbw-planning/milestones/upgrade-assurance/phases/01-upgrade-command/01-VERIFICATION.md +0 -144
  128. data/.vbw-planning/milestones/upgrade-assurance/phases/01-upgrade-command/PLAN-01-SUMMARY.md +0 -43
  129. data/.vbw-planning/milestones/upgrade-assurance/phases/01-upgrade-command/PLAN-01.md +0 -405
  130. data/.vbw-planning/milestones/upgrade-assurance/phases/02-config-deprecation/PLAN-01-SUMMARY.md +0 -27
  131. data/.vbw-planning/milestones/upgrade-assurance/phases/02-config-deprecation/PLAN-01.md +0 -303
  132. data/.vbw-planning/milestones/upgrade-assurance/phases/03-upgrade-skill-docs/03-VERIFICATION.md +0 -380
  133. data/.vbw-planning/milestones/upgrade-assurance/phases/03-upgrade-skill-docs/PLAN-01-SUMMARY.md +0 -36
  134. data/.vbw-planning/milestones/upgrade-assurance/phases/03-upgrade-skill-docs/PLAN-01.md +0 -652
  135. data/.vbw-planning/phases/01-aia-certificate-resolution/.context-dev.md +0 -17
  136. data/.vbw-planning/phases/01-aia-certificate-resolution/PLAN-01-SUMMARY.md +0 -26
  137. data/.vbw-planning/phases/01-aia-certificate-resolution/PLAN-01.md +0 -71
  138. data/.vbw-planning/phases/01-aia-certificate-resolution/PLAN-02-SUMMARY.md +0 -16
  139. data/.vbw-planning/phases/01-aia-certificate-resolution/PLAN-02.md +0 -56
  140. data/.vbw-planning/phases/01-aia-certificate-resolution/PLAN-03-SUMMARY.md +0 -17
  141. data/.vbw-planning/phases/01-aia-certificate-resolution/PLAN-03.md +0 -98
@@ -1,488 +0,0 @@
1
- ---
2
- phase: 5
3
- plan: "02"
4
- title: download-job-integration-docs
5
- type: execute
6
- wave: 2
7
- depends_on:
8
- - "PLAN-01"
9
- cross_phase_deps: []
10
- autonomous: true
11
- effort_override: thorough
12
- skills_used:
13
- - sm-configure
14
- - sm-job
15
- - sm-engine-test
16
- files_modified:
17
- - lib/source_monitor/images/downloader.rb
18
- - app/jobs/source_monitor/download_content_images_job.rb
19
- - lib/source_monitor/fetching/feed_fetcher/entry_processor.rb
20
- - lib/source_monitor.rb
21
- - test/lib/source_monitor/images/downloader_test.rb
22
- - test/jobs/source_monitor/download_content_images_job_test.rb
23
- - test/lib/source_monitor/fetching/feed_fetcher/entry_processor_test.rb
24
- - .claude/skills/sm-configure/SKILL.md
25
- - .claude/skills/sm-configure/reference/configuration-reference.md
26
- must_haves:
27
- truths:
28
- - "Running `PARALLEL_WORKERS=1 bin/rails test test/lib/source_monitor/images/downloader_test.rb` exits 0 with 0 failures"
29
- - "Running `PARALLEL_WORKERS=1 bin/rails test test/jobs/source_monitor/download_content_images_job_test.rb` exits 0 with 0 failures"
30
- - "Running `PARALLEL_WORKERS=1 bin/rails test test/lib/source_monitor/fetching/feed_fetcher/entry_processor_test.rb` exits 0 with 0 failures"
31
- - "Running `bin/rubocop lib/source_monitor/images/downloader.rb app/jobs/source_monitor/download_content_images_job.rb lib/source_monitor/fetching/feed_fetcher/entry_processor.rb` exits 0 with no offenses"
32
- - "When `config.images.download_to_active_storage` is false (default), no DownloadContentImagesJob is enqueued after item creation"
33
- - "When `config.images.download_to_active_storage` is true, DownloadContentImagesJob is enqueued for newly created items that have HTML content"
34
- - "DownloadContentImagesJob downloads images, attaches to ItemContent, and rewrites item.content with Active Storage URLs"
35
- - "Images that fail to download are left with original URLs in the content (graceful fallback)"
36
- - "Images larger than max_download_size are skipped and original URL preserved"
37
- - "Images with disallowed content types are skipped and original URL preserved"
38
- - "sm-configure skill documents the new config.images section"
39
- artifacts:
40
- - path: "lib/source_monitor/images/downloader.rb"
41
- provides: "Downloads a single image via Faraday, validates size and content type"
42
- contains: "class Downloader"
43
- - path: "app/jobs/source_monitor/download_content_images_job.rb"
44
- provides: "Background job to download and attach content images"
45
- contains: "class DownloadContentImagesJob"
46
- - path: "lib/source_monitor/fetching/feed_fetcher/entry_processor.rb"
47
- provides: "Integration hook to enqueue image download after item creation"
48
- contains: "enqueue_image_download"
49
- - path: ".claude/skills/sm-configure/SKILL.md"
50
- provides: "Updated skill with config.images section documented"
51
- contains: "config.images"
52
- key_links:
53
- - from: "download_content_images_job.rb#perform"
54
- to: "REQ-24"
55
- via: "Downloads inline images to Active Storage"
56
- - from: "entry_processor.rb#enqueue_image_download"
57
- to: "REQ-24"
58
- via: "Enqueues download job when config enabled and item has content"
59
- - from: "downloader.rb"
60
- to: "REQ-24"
61
- via: "Validates size and content type before download"
62
- - from: "sm-configure/SKILL.md"
63
- to: "REQ-24"
64
- via: "Configuration documented in sm-configure skill"
65
- ---
66
- <objective>
67
- Build the image download job, single-image downloader service, integration hook in the entry processor, and update the sm-configure skill. This plan connects Plan 01's foundational pieces into a working end-to-end image download pipeline. REQ-24.
68
- </objective>
69
- <context>
70
- @lib/source_monitor/images/content_rewriter.rb -- (from Plan 01) Provides `image_urls` to get URLs and `rewrite { |url| new_url }` to transform HTML. The job uses `image_urls` to get the list, downloads each image, attaches to Active Storage, then uses `rewrite` to replace original URLs with Active Storage serving URLs.
71
-
72
- @app/models/source_monitor/item_content.rb -- (from Plan 01) Has `has_many_attached :images`. The job attaches downloaded images here via `item_content.images.attach(blob)`.
73
-
74
- @lib/source_monitor/configuration/images_settings.rb -- (from Plan 01) Provides `download_enabled?`, `max_download_size`, `download_timeout`, `allowed_content_types`.
75
-
76
- @lib/source_monitor/fetching/feed_fetcher/entry_processor.rb -- The integration point. After `ItemCreator.call` returns a created item, if images download is enabled and the item has HTML content in `item.content`, enqueue `DownloadContentImagesJob.perform_later(item.id)`. Only for newly created items (not updates).
77
-
78
- @lib/source_monitor/http.rb -- Faraday client factory. The Downloader creates its own Faraday connection: no retry (images are best-effort), short timeout from config, Accept header for images.
79
-
80
- @app/jobs/source_monitor/application_job.rb -- Base job class. DownloadContentImagesJob inherits from this. Uses `source_monitor_queue :fetch` (reuse fetch queue since image downloads are I/O-bound like fetches).
81
-
82
- @app/jobs/source_monitor/fetch_feed_job.rb -- Pattern to follow for job structure: `discard_on` for deserialization errors, simple `perform` that delegates to service objects.
83
-
84
- @test/test_helper.rb -- WebMock disables external HTTP. Image download tests need WebMock stubs. Use `stub_request(:get, url).to_return(body: png_bytes, headers: { "Content-Type" => "image/png" })`.
85
-
86
- @.claude/skills/sm-configure/SKILL.md -- Needs a new section for `config.images` with examples.
87
-
88
- @app/models/source_monitor/item.rb -- The item model. `item.content` is a text column on `sourcemon_items` storing the feed entry content (HTML). This is where inline images live. The job reads `item.content`, rewrites it, and saves it back. `item_content` (separate table) stores scraped_html/scraped_content from scraping -- that happens later and is separate from feed content.
89
-
90
- **Key design decisions:**
91
- 1. **Job takes `item_id`** (not item_content_id). The feed content with inline images is in `item.content`. The job reads `item.content`, downloads images, attaches blobs to `item_content.images` (building item_content if needed), and writes the rewritten HTML back to `item.content`.
92
- 2. Downloader is a service object that downloads one image: takes URL, returns `{io:, filename:, content_type:}` or nil on failure.
93
- 3. The job is idempotent: if `item_content.images.attached?`, it skips re-downloading.
94
- 4. The job runs on the fetch queue (I/O-bound work).
95
- 5. The job wraps each download in begin/rescue -- one failing image does not block others.
96
- 6. After all images are processed, rewrite the HTML once with all successful replacements. Failed images keep their original URLs.
97
- 7. Only newly created items trigger image downloads (not updates).
98
- </context>
99
- <tasks>
100
- <task type="auto">
101
- <name>create-image-downloader</name>
102
- <files>
103
- lib/source_monitor/images/downloader.rb
104
- lib/source_monitor.rb
105
- test/lib/source_monitor/images/downloader_test.rb
106
- </files>
107
- <action>
108
- **Create `lib/source_monitor/images/downloader.rb`:**
109
-
110
- A service object that downloads a single image from a URL, validates it, and returns the result.
111
-
112
- ```ruby
113
- # frozen_string_literal: true
114
-
115
- require "faraday"
116
- require "securerandom"
117
-
118
- module SourceMonitor
119
- module Images
120
- class Downloader
121
- Result = Struct.new(:io, :filename, :content_type, :byte_size, keyword_init: true)
122
-
123
- attr_reader :url, :settings
124
-
125
- def initialize(url, settings: nil)
126
- @url = url
127
- @settings = settings || SourceMonitor.config.images
128
- end
129
-
130
- # Downloads the image and returns a Result, or nil if download fails
131
- # or the image does not meet validation criteria.
132
- def call
133
- response = fetch_image
134
- return unless response
135
-
136
- content_type = response.headers["content-type"]&.split(";")&.first&.strip&.downcase
137
- return unless allowed_content_type?(content_type)
138
-
139
- body = response.body
140
- return unless body && body.bytesize > 0
141
- return if body.bytesize > settings.max_download_size
142
-
143
- filename = derive_filename(url, content_type)
144
-
145
- Result.new(
146
- io: StringIO.new(body),
147
- filename: filename,
148
- content_type: content_type,
149
- byte_size: body.bytesize
150
- )
151
- rescue Faraday::Error, URI::InvalidURIError, Timeout::Error => _error
152
- nil
153
- end
154
-
155
- private
156
-
157
- def fetch_image
158
- connection = Faraday.new do |f|
159
- f.options.timeout = settings.download_timeout
160
- f.options.open_timeout = [settings.download_timeout / 2, 5].min
161
- f.headers["User-Agent"] = SourceMonitor.config.http.user_agent || "SourceMonitor/#{SourceMonitor::VERSION}"
162
- f.headers["Accept"] = "image/*"
163
- f.adapter Faraday.default_adapter
164
- end
165
-
166
- response = connection.get(url)
167
- return response if response.status == 200
168
-
169
- nil
170
- end
171
-
172
- def allowed_content_type?(content_type)
173
- return false if content_type.blank?
174
-
175
- settings.allowed_content_types.include?(content_type)
176
- end
177
-
178
- def derive_filename(image_url, content_type)
179
- uri = URI.parse(image_url)
180
- basename = File.basename(uri.path) if uri.path.present?
181
-
182
- if basename.present? && basename.include?(".")
183
- basename
184
- else
185
- ext = Rack::Mime::MIME_TYPES.invert[content_type] || ".bin"
186
- "image-#{SecureRandom.hex(8)}#{ext}"
187
- end
188
- rescue URI::InvalidURIError
189
- ext = Rack::Mime::MIME_TYPES.invert[content_type] || ".bin"
190
- "image-#{SecureRandom.hex(8)}#{ext}"
191
- end
192
- end
193
- end
194
- end
195
- ```
196
-
197
- **Update `lib/source_monitor.rb`:**
198
-
199
- Add `autoload :Downloader, "source_monitor/images/downloader"` inside the `module Images` block (added in Plan 01).
200
-
201
- **Create `test/lib/source_monitor/images/downloader_test.rb`:**
202
-
203
- Use WebMock stubs for all HTTP interactions. Tests:
204
-
205
- 1. Downloads valid image and returns Result with io, filename, content_type, byte_size
206
- 2. Returns nil for HTTP error (404, 500)
207
- 3. Returns nil for disallowed content type (e.g., text/html)
208
- 4. Returns nil for image exceeding max_download_size
209
- 5. Returns nil for empty response body
210
- 6. Returns nil for network timeout (stub with `to_timeout`)
211
- 7. Derives filename from URL path when available
212
- 8. Generates random filename when URL has no extension
213
- 9. Uses configured download_timeout
214
- 10. Uses configured allowed_content_types
215
- </action>
216
- <verify>
217
- Run `PARALLEL_WORKERS=1 bin/rails test test/lib/source_monitor/images/downloader_test.rb` and confirm all tests pass. Run `bin/rubocop lib/source_monitor/images/downloader.rb` and confirm no offenses.
218
- </verify>
219
- <done>
220
- Downloader service created. Downloads single images, validates content type and size, derives filenames. Returns nil on any failure for graceful fallback. Tests cover all success and failure scenarios.
221
- </done>
222
- </task>
223
- <task type="auto">
224
- <name>create-download-job</name>
225
- <files>
226
- app/jobs/source_monitor/download_content_images_job.rb
227
- test/jobs/source_monitor/download_content_images_job_test.rb
228
- </files>
229
- <action>
230
- **Create `app/jobs/source_monitor/download_content_images_job.rb`:**
231
-
232
- The job takes `item_id`, reads `item.content` for inline images, downloads them, attaches to `item_content.images`, and rewrites `item.content` with Active Storage URLs.
233
-
234
- ```ruby
235
- # frozen_string_literal: true
236
-
237
- module SourceMonitor
238
- class DownloadContentImagesJob < ApplicationJob
239
- source_monitor_queue :fetch
240
-
241
- discard_on ActiveJob::DeserializationError
242
-
243
- def perform(item_id)
244
- item = SourceMonitor::Item.find_by(id: item_id)
245
- return unless item
246
- return unless SourceMonitor.config.images.download_enabled?
247
-
248
- html = item.content
249
- return if html.blank?
250
-
251
- # Build or find item_content for attachment storage
252
- item_content = item.item_content || item.build_item_content
253
-
254
- # Skip if images already attached (idempotency)
255
- return if item_content.persisted? && item_content.images.attached?
256
-
257
- base_url = item.url
258
- rewriter = SourceMonitor::Images::ContentRewriter.new(html, base_url: base_url)
259
- image_urls = rewriter.image_urls
260
- return if image_urls.empty?
261
-
262
- # Save item_content first so we can attach blobs to it
263
- item_content.save! unless item_content.persisted?
264
-
265
- # Download images and build URL mapping
266
- url_mapping = download_images(item_content, image_urls)
267
- return if url_mapping.empty?
268
-
269
- # Rewrite HTML with Active Storage URLs
270
- rewritten_html = rewriter.rewrite do |original_url|
271
- url_mapping[original_url]
272
- end
273
-
274
- # Update the item content with rewritten HTML
275
- item.update!(content: rewritten_html)
276
- end
277
-
278
- private
279
-
280
- def download_images(item_content, image_urls)
281
- url_mapping = {}
282
- settings = SourceMonitor.config.images
283
-
284
- image_urls.each do |image_url|
285
- result = SourceMonitor::Images::Downloader.new(image_url, settings: settings).call
286
- next unless result
287
-
288
- blob = ActiveStorage::Blob.create_and_upload!(
289
- io: result.io,
290
- filename: result.filename,
291
- content_type: result.content_type
292
- )
293
- item_content.images.attach(blob)
294
-
295
- # Generate a serving URL for the blob
296
- url_mapping[image_url] = Rails.application.routes.url_helpers.rails_blob_path(blob, only_path: true)
297
- rescue StandardError => _error
298
- # Individual image failure should not block others.
299
- # Original URL will be preserved (graceful fallback).
300
- next
301
- end
302
-
303
- url_mapping
304
- end
305
- end
306
- end
307
- ```
308
-
309
- **Create `test/jobs/source_monitor/download_content_images_job_test.rb`:**
310
-
311
- Tests using WebMock stubs and Active Storage test helpers:
312
-
313
- 1. Downloads images and rewrites item.content HTML when config enabled
314
- 2. Skips when config disabled (download_to_active_storage is false)
315
- 3. Skips when item not found
316
- 4. Skips when item.content is blank
317
- 5. Skips when images already attached (idempotency)
318
- 6. Skips when no image URLs found in content
319
- 7. Gracefully handles individual image download failure (other images still processed)
320
- 8. Preserves original URL for failed downloads in rewritten HTML
321
- 9. Attaches downloaded images to item_content.images
322
- 10. Creates item_content if it does not exist yet
323
-
324
- For each test:
325
- - Set up `SourceMonitor.configure { |c| c.images.download_to_active_storage = true }` where needed
326
- - Create a source and item with `content: '<p><img src="https://example.com/photo.jpg"></p>'`
327
- - Stub WebMock for the image URL returning a small PNG binary
328
- - Call `DownloadContentImagesJob.perform_now(item.id)`
329
- - Assert on `item.reload.content` for rewritten URLs and `item.item_content.images.count`
330
- </action>
331
- <verify>
332
- Run `PARALLEL_WORKERS=1 bin/rails test test/jobs/source_monitor/download_content_images_job_test.rb` and confirm all tests pass. Run `bin/rubocop app/jobs/source_monitor/download_content_images_job.rb` and confirm no offenses.
333
- </verify>
334
- <done>
335
- DownloadContentImagesJob created. Takes item_id, reads item.content, downloads images via Downloader, attaches to item_content via Active Storage, rewrites HTML with blob paths. Idempotent, graceful failure handling. Tests cover all scenarios.
336
- </done>
337
- </task>
338
- <task type="auto">
339
- <name>wire-integration-and-update-docs</name>
340
- <files>
341
- lib/source_monitor/fetching/feed_fetcher/entry_processor.rb
342
- test/lib/source_monitor/fetching/feed_fetcher/entry_processor_test.rb
343
- .claude/skills/sm-configure/SKILL.md
344
- .claude/skills/sm-configure/reference/configuration-reference.md
345
- </files>
346
- <action>
347
- **Modify `lib/source_monitor/fetching/feed_fetcher/entry_processor.rb`:**
348
-
349
- Add an integration hook after item creation. In the `process_feed_entries` method, after the `SourceMonitor::Events.after_item_created` call (line 40), add:
350
-
351
- ```ruby
352
- enqueue_image_download(result.item)
353
- ```
354
-
355
- This is inside the `if result.created?` block, so it only fires for new items.
356
-
357
- Add a private method:
358
-
359
- ```ruby
360
- def enqueue_image_download(item)
361
- return unless SourceMonitor.config.images.download_enabled?
362
- return if item.content.blank?
363
-
364
- SourceMonitor::DownloadContentImagesJob.perform_later(item.id)
365
- rescue StandardError => error
366
- # Image download enqueue failure must never break feed processing
367
- if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
368
- Rails.logger.error("[SourceMonitor] Failed to enqueue image download for item #{item.id}: #{error.message}")
369
- end
370
- end
371
- ```
372
-
373
- **Create/update entry processor test:**
374
-
375
- Check if `test/lib/source_monitor/fetching/feed_fetcher/entry_processor_test.rb` exists. If not, create it with a proper test class. Add tests:
376
-
377
- 1. When images download enabled AND item created with content containing img tags, asserts DownloadContentImagesJob is enqueued with the item's ID
378
- 2. When images download disabled (default), asserts no DownloadContentImagesJob enqueued
379
- 3. When item is updated (not created), asserts no job enqueued
380
- 4. When item.content is blank, asserts no job enqueued
381
- 5. When enqueue raises an error, item creation still succeeds (graceful failure)
382
-
383
- Use `assert_enqueued_with(job: SourceMonitor::DownloadContentImagesJob, args: [item.id])` and `assert_no_enqueued_jobs(only: SourceMonitor::DownloadContentImagesJob)`.
384
-
385
- Test setup needs a source, a mock feed with entries (use Feedjira or a mock object), and configure images download as needed per test.
386
-
387
- **Update `.claude/skills/sm-configure/SKILL.md`:**
388
-
389
- 1. Add `| Images | \`config.images\` | \`ImagesSettings\` |` to the Configuration Sections table.
390
- 2. Add a new Quick Example section after "Authentication (Devise)":
391
-
392
- ```markdown
393
- ### Image Downloads (Active Storage)
394
- ```ruby
395
- config.images.download_to_active_storage = true
396
- config.images.max_download_size = 5 * 1024 * 1024 # 5 MB
397
- config.images.download_timeout = 15
398
- config.images.allowed_content_types = %w[image/jpeg image/png image/webp]
399
- ```
400
-
401
- 3. Add `| \`lib/source_monitor/configuration/images_settings.rb\` | Image download settings |` to the Key Source Files table.
402
-
403
- **Update `.claude/skills/sm-configure/reference/configuration-reference.md`:**
404
-
405
- Add a complete "Images Settings" section documenting all ImagesSettings options:
406
-
407
- | Setting | Type | Default | Description |
408
- |---|---|---|---|
409
- | download_to_active_storage | Boolean | false | Enable background image downloading |
410
- | max_download_size | Integer | 10485760 (10 MB) | Maximum image file size in bytes |
411
- | download_timeout | Integer | 30 | HTTP timeout for image downloads in seconds |
412
- | allowed_content_types | Array | ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"] | Permitted MIME types |
413
-
414
- Include a usage example and note about Active Storage prerequisites (host app must have Active Storage installed).
415
- </action>
416
- <verify>
417
- Run `PARALLEL_WORKERS=1 bin/rails test test/lib/source_monitor/fetching/feed_fetcher/entry_processor_test.rb` and confirm all tests pass. Run `bin/rubocop lib/source_monitor/fetching/feed_fetcher/entry_processor.rb` and confirm no offenses. Verify `grep -n 'config.images' .claude/skills/sm-configure/SKILL.md` returns matches.
418
- </verify>
419
- <done>
420
- Integration hook wired in entry_processor. DownloadContentImagesJob enqueued with item.id for newly created items with HTML content when config enabled. Entry processor tests verify all scenarios. sm-configure skill and reference updated with config.images documentation.
421
- </done>
422
- </task>
423
- <task type="auto">
424
- <name>full-plan-02-verification</name>
425
- <files>
426
- lib/source_monitor/images/downloader.rb
427
- app/jobs/source_monitor/download_content_images_job.rb
428
- lib/source_monitor/fetching/feed_fetcher/entry_processor.rb
429
- .claude/skills/sm-configure/SKILL.md
430
- </files>
431
- <action>
432
- Run the full test suite and linting to confirm no regressions:
433
-
434
- 1. `PARALLEL_WORKERS=1 bin/rails test test/lib/source_monitor/images/downloader_test.rb test/jobs/source_monitor/download_content_images_job_test.rb test/lib/source_monitor/fetching/feed_fetcher/entry_processor_test.rb` -- all new tests pass
435
- 2. `bin/rails test` -- full suite passes with 874+ runs and 0 failures
436
- 3. `bin/rubocop` -- zero offenses
437
- 4. `bin/brakeman --no-pager` -- zero warnings
438
- 5. End-to-end verification:
439
- - Confirm `config.images.download_to_active_storage = true` enables image downloads
440
- - Confirm default (false) means no jobs enqueued
441
- - Confirm the job downloads images, attaches them, and rewrites item.content
442
- - Confirm failed downloads preserve original URLs
443
- - Confirm sm-configure skill documents `config.images` section
444
- 6. Review all modified files for consistency:
445
- - Job inherits from ApplicationJob, uses source_monitor_queue :fetch
446
- - Job takes item_id, reads item.content, rewrites item.content
447
- - Downloader handles all failure modes gracefully (returns nil)
448
- - Entry processor integration is wrapped in rescue (never breaks feed processing)
449
- - ContentRewriter preserves non-image HTML attributes and structure
450
-
451
- If any test failures, RuboCop offenses, or Brakeman warnings are found, fix them before completing.
452
- </action>
453
- <verify>
454
- `bin/rails test` exits 0 with 874+ runs, 0 failures. `bin/rubocop` exits 0 with 0 offenses. `bin/brakeman --no-pager` exits 0 with 0 warnings. `grep -n 'config.images' .claude/skills/sm-configure/SKILL.md` returns matches.
455
- </verify>
456
- <done>
457
- Plan 02 complete. Full image download pipeline is operational: config enables feature, entry processor enqueues job for new items with content, job downloads images via Downloader, attaches to item_content via Active Storage, rewrites item.content with blob URLs. Graceful fallback on all failure modes. Documentation updated. Full test suite passes.
458
- </done>
459
- </task>
460
- </tasks>
461
- <verification>
462
- 1. `PARALLEL_WORKERS=1 bin/rails test test/lib/source_monitor/images/downloader_test.rb` -- all tests pass
463
- 2. `PARALLEL_WORKERS=1 bin/rails test test/jobs/source_monitor/download_content_images_job_test.rb` -- all tests pass
464
- 3. `PARALLEL_WORKERS=1 bin/rails test test/lib/source_monitor/fetching/feed_fetcher/entry_processor_test.rb` -- all tests pass
465
- 4. `bin/rails test` -- 874+ runs, 0 failures
466
- 5. `bin/rubocop` -- 0 offenses
467
- 6. `bin/brakeman --no-pager` -- 0 warnings
468
- 7. `grep -n 'class Downloader' lib/source_monitor/images/downloader.rb` returns a match
469
- 8. `grep -n 'class DownloadContentImagesJob' app/jobs/source_monitor/download_content_images_job.rb` returns a match
470
- 9. `grep -n 'enqueue_image_download' lib/source_monitor/fetching/feed_fetcher/entry_processor.rb` returns a match
471
- 10. `grep -n 'config.images' .claude/skills/sm-configure/SKILL.md` returns matches
472
- 11. `grep -n 'ImagesSettings' .claude/skills/sm-configure/reference/configuration-reference.md` returns matches
473
- </verification>
474
- <success_criteria>
475
- - Downloader service downloads images, validates size/content-type, returns nil on failure (REQ-24)
476
- - DownloadContentImagesJob takes item_id, orchestrates download/attach/rewrite pipeline on item.content (REQ-24)
477
- - Job is idempotent (skips if images already attached) (REQ-24)
478
- - Failed individual downloads preserve original URLs in content (REQ-24 graceful fallback)
479
- - Entry processor enqueues job only for newly created items when config enabled (REQ-24)
480
- - Entry processor integration never breaks feed processing on failure (REQ-24 graceful)
481
- - Default config (disabled) means zero behavior change to existing pipeline (REQ-24 defaults false)
482
- - sm-configure skill documents config.images section (REQ-24 documentation)
483
- - All existing tests pass (no regressions)
484
- - RuboCop clean, Brakeman clean
485
- </success_criteria>
486
- <output>
487
- .vbw-planning/phases/05-active-storage-images/PLAN-02-SUMMARY.md
488
- </output>
@@ -1,100 +0,0 @@
1
- ---
2
- phase: 06-netflix-feed-fix
3
- plan: PLAN-01
4
- tier: standard
5
- result: PASS
6
- passed: 18
7
- failed: 0
8
- total: 18
9
- date: 2026-02-12
10
- ---
11
-
12
- ## Must-Have Checks
13
-
14
- | # | Truth/Condition | Status | Evidence |
15
- |---|----------------|--------|----------|
16
- | 1 | HTTP tests pass | PASS | `PARALLEL_WORKERS=1 bin/rails test test/lib/source_monitor/http_test.rb` - 13 runs, 45 assertions, 0 failures |
17
- | 2 | FeedFetcher tests pass | PASS | `PARALLEL_WORKERS=1 bin/rails test test/lib/source_monitor/fetching/feed_fetcher_test.rb` - 65 runs, 277 assertions, 0 failures |
18
- | 3 | RuboCop passes | PASS | 4 files inspected, no offenses detected |
19
- | 4 | Full test suite passes | PASS | 973 runs, 3114 assertions, 0 failures, 0 errors, 0 skips |
20
- | 5 | cert_store in http.rb | PASS | Found at lines 78 and 83: `connection.ssl.cert_store` and `def default_cert_store` |
21
- | 6 | ssl_ca_file in http_settings.rb | PASS | Found at lines 17 and 37: `attr_accessor :ssl_ca_file` and initialization |
22
- | 7 | Netflix VCR cassette exists | PASS | File exists at `test/vcr_cassettes/source_monitor/fetching/netflix_medium_rss.yml` and contains "netflixtechblog" |
23
-
24
- ## Artifact Checks
25
-
26
- | Artifact | Exists | Contains | Status |
27
- |----------|--------|----------|--------|
28
- | lib/source_monitor/http.rb | YES | "cert_store" | PASS |
29
- | lib/source_monitor/configuration/http_settings.rb | YES | "ssl_ca_file" | PASS |
30
- | test/lib/source_monitor/http_test.rb | YES | "ssl" | PASS |
31
- | test/lib/source_monitor/fetching/feed_fetcher_test.rb | YES | "netflix" | PASS |
32
- | test/vcr_cassettes/source_monitor/fetching/netflix_medium_rss.yml | YES | "netflixtechblog" | PASS |
33
-
34
- ## Key Link Checks
35
-
36
- | From | To | Via | Status |
37
- |------|----|----|--------|
38
- | http.rb#configure_ssl | REQ-25 | Configures Faraday SSL with system cert store (lines 66-80) | PASS |
39
- | http_settings.rb#ssl_ca_file | REQ-25 | Exposes configurable SSL CA file/path (lines 17-19, 37-39) | PASS |
40
- | feed_fetcher_test.rb#netflix_regression | REQ-25 | VCR cassette proves Netflix Tech Blog feed parses successfully (lines 1142-1158) | PASS |
41
-
42
- ## Convention Compliance
43
-
44
- | Convention | File | Status | Detail |
45
- |-----------|------|--------|--------|
46
- | frozen_string_literal | lib/source_monitor/http.rb | PASS | Line 1 |
47
- | frozen_string_literal | lib/source_monitor/configuration/http_settings.rb | PASS | Line 1 |
48
- | frozen_string_literal | test/lib/source_monitor/http_test.rb | PASS | Line 1 |
49
- | frozen_string_literal | test/lib/source_monitor/fetching/feed_fetcher_test.rb | PASS | Line 1 |
50
- | RuboCop omakase | All modified files | PASS | 0 offenses |
51
- | Minitest | test/lib/source_monitor/http_test.rb | PASS | 13 tests, all passing |
52
-
53
- ## Anti-Pattern Scan
54
-
55
- | Pattern | Found | Location | Severity |
56
- |---------|-------|----------|----------|
57
- | Hard-coded credentials | NO | N/A | - |
58
- | Boolean state columns | NO | N/A | - |
59
- | Service object business logic | NO | N/A | - |
60
- | N+1 queries | NO | N/A | - |
61
-
62
- ## Requirement Mapping
63
-
64
- | Requirement | Plan Ref | Artifact Evidence | Status |
65
- |-------------|----------|------------------|--------|
66
- | REQ-25: Fix Netflix Tech Blog feed SSL errors | PLAN-01 objective | http.rb lines 66-84: SSL cert store configuration | PASS |
67
- | REQ-25: Configurable SSL options | PLAN-01 must_have | http_settings.rb lines 17-19, 37-39: ssl_ca_file, ssl_ca_path, ssl_verify | PASS |
68
- | REQ-25: Netflix regression test | PLAN-01 must_have | feed_fetcher_test.rb lines 1142-1158 + VCR cassette | PASS |
69
-
70
- ## Summary
71
-
72
- **Tier:** Standard (15-25 checks)
73
-
74
- **Result:** PASS
75
-
76
- **Passed:** 18/18
77
-
78
- **Failed:** None
79
-
80
- All must-have truths verified successfully:
81
- - HTTP and FeedFetcher tests pass with zero failures
82
- - RuboCop passes with zero offenses across all modified files
83
- - Full test suite passes (973 runs, 0 failures)
84
- - SSL cert store configuration present in http.rb
85
- - Configurable SSL options (ssl_ca_file, ssl_ca_path, ssl_verify) present in http_settings.rb
86
- - Netflix Tech Blog VCR cassette exists and contains expected content
87
-
88
- All artifacts verified:
89
- - lib/source_monitor/http.rb implements SSL cert store configuration
90
- - lib/source_monitor/configuration/http_settings.rb exposes SSL configuration options
91
- - test/lib/source_monitor/http_test.rb contains 6 SSL-specific tests (lines 117-153)
92
- - test/lib/source_monitor/fetching/feed_fetcher_test.rb contains Netflix regression test (lines 1142-1158)
93
- - test/vcr_cassettes/source_monitor/fetching/netflix_medium_rss.yml captured from real Netflix Tech Blog feed
94
-
95
- All key links verified:
96
- - configure_ssl method (http.rb lines 66-80) solves REQ-25 by using OpenSSL::X509::Store with set_default_paths
97
- - HTTPSettings attributes (http_settings.rb) provide configurability for non-standard environments
98
- - Netflix regression test with VCR cassette proves the fix works in practice
99
-
100
- No regressions detected. All conventions followed. REQ-25 fully satisfied.
@@ -1,37 +0,0 @@
1
- ---
2
- plan: "01"
3
- phase: 6
4
- title: ssl-cert-store-configuration
5
- status: COMPLETE
6
- requirement: REQ-25
7
- test_runs: 973
8
- test_assertions: 3114
9
- test_failures: 0
10
- rubocop_offenses: 0
11
- brakeman_warnings: 0
12
- commits:
13
- - hash: c673d00
14
- message: "feat(06-01): add-ssl-settings-to-http-settings"
15
- - hash: f084129
16
- message: "feat(06-01): configure-faraday-ssl-cert-store"
17
- - hash: d2e3997
18
- message: "test(06-01): add-ssl-unit-tests"
19
- - hash: 6f0bbe8
20
- message: "test(06-01): record-netflix-vcr-cassette-and-regression-test"
21
- deviations: none
22
- ---
23
-
24
- ## What Was Built
25
-
26
- - **HTTPSettings SSL options** -- Added ssl_ca_file, ssl_ca_path, ssl_verify attr_accessors with safe defaults (verify=true, ca_file/ca_path=nil). Follows existing settings pattern.
27
- - **Faraday SSL cert store** -- Every Faraday connection now gets an OpenSSL::X509::Store initialized with set_default_paths, loading all system-trusted CAs including intermediates. ssl_ca_file and ssl_ca_path override the store when set. General fix applying to ALL connections, not Netflix-specific.
28
- - **SSL unit tests** -- 5 new tests covering default cert store, ca_file override, ca_path override, verify-true default, verify-false escape hatch. 13 total HTTP tests.
29
- - **Netflix VCR cassette** -- Recorded from real Netflix Tech Blog feed (netflixtechblog.com/feed). Trimmed to 3 entries for manageable fixture size. Regression test parses as RSS and validates Netflix title and entries.
30
-
31
- ## Files Modified
32
-
33
- - `lib/source_monitor/configuration/http_settings.rb` -- added ssl_ca_file, ssl_ca_path, ssl_verify settings
34
- - `lib/source_monitor/http.rb` -- added require "openssl", configure_ssl method, default_cert_store method
35
- - `test/lib/source_monitor/http_test.rb` -- 5 new SSL configuration tests
36
- - `test/lib/source_monitor/fetching/feed_fetcher_test.rb` -- Netflix Tech Blog regression test
37
- - `test/vcr_cassettes/source_monitor/fetching/netflix_medium_rss.yml` -- new VCR cassette