source_monitor 0.2.0 → 0.3.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 (196) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/agents/rails-concern.md +464 -0
  3. data/.claude/agents/rails-controller.md +424 -0
  4. data/.claude/agents/rails-hotwire.md +446 -0
  5. data/.claude/agents/rails-implement.md +374 -0
  6. data/.claude/agents/rails-job.md +334 -0
  7. data/.claude/agents/rails-lint.md +294 -0
  8. data/.claude/agents/rails-mailer.md +371 -0
  9. data/.claude/agents/rails-migration.md +449 -0
  10. data/.claude/agents/rails-model.md +420 -0
  11. data/.claude/agents/rails-policy.md +443 -0
  12. data/.claude/agents/rails-presenter.md +427 -0
  13. data/.claude/agents/rails-query.md +412 -0
  14. data/.claude/agents/rails-review.md +490 -0
  15. data/.claude/agents/rails-service.md +458 -0
  16. data/.claude/agents/rails-state-records.md +465 -0
  17. data/.claude/agents/rails-tdd.md +314 -0
  18. data/.claude/agents/rails-test.md +441 -0
  19. data/.claude/agents/rails-view-component.md +418 -0
  20. data/.claude/hooks/block-secrets.sh +52 -0
  21. data/.claude/settings.json +85 -0
  22. data/.claude/skills/action-cable-patterns/SKILL.md +296 -0
  23. data/.claude/skills/action-mailer-patterns/SKILL.md +295 -0
  24. data/.claude/skills/active-storage-setup/SKILL.md +311 -0
  25. data/.claude/skills/api-versioning/SKILL.md +294 -0
  26. data/.claude/skills/authentication-flow/SKILL.md +335 -0
  27. data/.claude/skills/authentication-flow/reference/current.md +248 -0
  28. data/.claude/skills/authentication-flow/reference/passwordless.md +253 -0
  29. data/.claude/skills/authentication-flow/reference/sessions.md +201 -0
  30. data/.claude/skills/authorization-pundit/SKILL.md +462 -0
  31. data/.claude/skills/caching-strategies/SKILL.md +350 -0
  32. data/.claude/skills/database-migrations/SKILL.md +354 -0
  33. data/.claude/skills/form-object-patterns/SKILL.md +399 -0
  34. data/.claude/skills/hotwire-patterns/SKILL.md +247 -0
  35. data/.claude/skills/hotwire-patterns/reference/stimulus.md +307 -0
  36. data/.claude/skills/hotwire-patterns/reference/tailwind-integration.md +112 -0
  37. data/.claude/skills/hotwire-patterns/reference/turbo-frames.md +158 -0
  38. data/.claude/skills/hotwire-patterns/reference/turbo-streams.md +218 -0
  39. data/.claude/skills/i18n-patterns/SKILL.md +320 -0
  40. data/.claude/skills/install/SKILL.md +367 -0
  41. data/.claude/skills/performance-optimization/SKILL.md +311 -0
  42. data/.claude/skills/rails-architecture/SKILL.md +259 -0
  43. data/.claude/skills/rails-architecture/reference/error-handling.md +333 -0
  44. data/.claude/skills/rails-architecture/reference/event-tracking.md +142 -0
  45. data/.claude/skills/rails-architecture/reference/layer-interactions.md +417 -0
  46. data/.claude/skills/rails-architecture/reference/multi-tenancy.md +152 -0
  47. data/.claude/skills/rails-architecture/reference/query-patterns.md +342 -0
  48. data/.claude/skills/rails-architecture/reference/service-patterns.md +286 -0
  49. data/.claude/skills/rails-architecture/reference/state-records.md +250 -0
  50. data/.claude/skills/rails-architecture/reference/testing-strategy.md +326 -0
  51. data/.claude/skills/rails-concern/SKILL.md +399 -0
  52. data/.claude/skills/rails-controller/SKILL.md +336 -0
  53. data/.claude/skills/rails-model-generator/SKILL.md +321 -0
  54. data/.claude/skills/rails-model-generator/reference/validations.md +298 -0
  55. data/.claude/skills/rails-presenter/SKILL.md +274 -0
  56. data/.claude/skills/rails-query-object/SKILL.md +289 -0
  57. data/.claude/skills/rails-service-object/SKILL.md +349 -0
  58. data/.claude/skills/solid-queue-setup/SKILL.md +307 -0
  59. data/.claude/skills/tdd-cycle/SKILL.md +359 -0
  60. data/.claude/skills/viewcomponent-patterns/SKILL.md +333 -0
  61. data/.gitignore +1 -0
  62. data/.rubocop.yml +2 -0
  63. data/.ruby-version +1 -1
  64. data/.vbw-planning/.notification-log.jsonl +192 -0
  65. data/.vbw-planning/.session-log.jsonl +871 -0
  66. data/.vbw-planning/PROJECT.md +51 -0
  67. data/.vbw-planning/REQUIREMENTS.md +50 -0
  68. data/.vbw-planning/SHIPPED.md +28 -0
  69. data/.vbw-planning/codebase/ARCHITECTURE.md +147 -0
  70. data/.vbw-planning/codebase/CONCERNS.md +99 -0
  71. data/.vbw-planning/codebase/CONVENTIONS.md +97 -0
  72. data/.vbw-planning/codebase/DEPENDENCIES.md +100 -0
  73. data/.vbw-planning/codebase/INDEX.md +86 -0
  74. data/.vbw-planning/codebase/META.md +42 -0
  75. data/.vbw-planning/codebase/PATTERNS.md +262 -0
  76. data/.vbw-planning/codebase/STACK.md +101 -0
  77. data/.vbw-planning/codebase/STRUCTURE.md +324 -0
  78. data/.vbw-planning/codebase/TESTING.md +154 -0
  79. data/.vbw-planning/config.json +12 -0
  80. data/.vbw-planning/discovery.json +24 -0
  81. data/.vbw-planning/milestones/default/ROADMAP.md +115 -0
  82. data/.vbw-planning/milestones/default/STATE.md +83 -0
  83. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01-SUMMARY.md +56 -0
  84. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01.md +187 -0
  85. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02-SUMMARY.md +64 -0
  86. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02.md +137 -0
  87. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01-SUMMARY.md +67 -0
  88. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01.md +142 -0
  89. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02-SUMMARY.md +64 -0
  90. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02.md +138 -0
  91. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03-SUMMARY.md +85 -0
  92. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03.md +147 -0
  93. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04-SUMMARY.md +63 -0
  94. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04.md +129 -0
  95. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05-SUMMARY.md +74 -0
  96. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05.md +154 -0
  97. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION-wave1.md +303 -0
  98. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION.md +510 -0
  99. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01-SUMMARY.md +61 -0
  100. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01.md +161 -0
  101. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02-SUMMARY.md +66 -0
  102. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02.md +132 -0
  103. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03-SUMMARY.md +59 -0
  104. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03.md +171 -0
  105. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04-SUMMARY.md +56 -0
  106. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04.md +152 -0
  107. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/04-CONTEXT.md +33 -0
  108. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01-SUMMARY.md +42 -0
  109. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01.md +119 -0
  110. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02-SUMMARY.md +52 -0
  111. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02.md +195 -0
  112. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03-SUMMARY.md +79 -0
  113. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03.md +130 -0
  114. data/CHANGELOG.md +28 -0
  115. data/CLAUDE.md +179 -0
  116. data/Gemfile +8 -0
  117. data/Gemfile.lock +114 -101
  118. data/Rakefile +2 -0
  119. data/app/assets/builds/source_monitor/application.css +2076 -0
  120. data/app/assets/builds/source_monitor/application.js +2758 -0
  121. data/app/assets/builds/source_monitor/application.js.map +7 -0
  122. data/app/controllers/source_monitor/application_controller.rb +2 -0
  123. data/app/controllers/source_monitor/health_controller.rb +2 -0
  124. data/app/controllers/source_monitor/import_sessions/bulk_configuration.rb +106 -0
  125. data/app/controllers/source_monitor/import_sessions/entry_annotation.rb +187 -0
  126. data/app/controllers/source_monitor/import_sessions/health_check_management.rb +112 -0
  127. data/app/controllers/source_monitor/import_sessions/opml_parser.rb +130 -0
  128. data/app/controllers/source_monitor/import_sessions_controller.rb +6 -507
  129. data/app/controllers/source_monitor/items_controller.rb +2 -0
  130. data/app/controllers/source_monitor/sources_controller.rb +0 -14
  131. data/app/helpers/source_monitor/application_helper.rb +4 -112
  132. data/app/helpers/source_monitor/health_badge_helper.rb +69 -0
  133. data/app/helpers/source_monitor/table_sort_helper.rb +53 -0
  134. data/app/jobs/source_monitor/application_job.rb +2 -0
  135. data/app/models/source_monitor/application_record.rb +2 -0
  136. data/app/models/source_monitor/log_entry.rb +0 -2
  137. data/config/coverage_baseline.json +217 -1862
  138. data/config/routes.rb +2 -0
  139. data/db/migrate/20251009103000_add_feed_content_readability_to_sources.rb +2 -0
  140. data/db/migrate/20251014171659_add_performance_indexes.rb +2 -0
  141. data/db/migrate/20251014172525_add_fetch_status_check_constraint.rb +2 -0
  142. data/db/migrate/20251108120116_refresh_fetch_status_constraint.rb +2 -0
  143. data/db/migrate/20260210204022_add_composite_index_to_log_entries.rb +17 -0
  144. data/lib/source_monitor/assets/bundler.rb +2 -0
  145. data/lib/source_monitor/assets.rb +2 -0
  146. data/lib/source_monitor/configuration/authentication_settings.rb +62 -0
  147. data/lib/source_monitor/configuration/events.rb +60 -0
  148. data/lib/source_monitor/configuration/fetching_settings.rb +27 -0
  149. data/lib/source_monitor/configuration/health_settings.rb +27 -0
  150. data/lib/source_monitor/configuration/http_settings.rb +43 -0
  151. data/lib/source_monitor/configuration/model_definition.rb +108 -0
  152. data/lib/source_monitor/configuration/models.rb +36 -0
  153. data/lib/source_monitor/configuration/realtime_settings.rb +95 -0
  154. data/lib/source_monitor/configuration/retention_settings.rb +45 -0
  155. data/lib/source_monitor/configuration/scraper_registry.rb +67 -0
  156. data/lib/source_monitor/configuration/scraping_settings.rb +39 -0
  157. data/lib/source_monitor/configuration/validation_definition.rb +32 -0
  158. data/lib/source_monitor/configuration.rb +12 -579
  159. data/lib/source_monitor/dashboard/queries/recent_activity_query.rb +138 -0
  160. data/lib/source_monitor/dashboard/queries/stats_query.rb +71 -0
  161. data/lib/source_monitor/dashboard/queries.rb +2 -195
  162. data/lib/source_monitor/engine.rb +2 -0
  163. data/lib/source_monitor/fetching/feed_fetcher/adaptive_interval.rb +141 -0
  164. data/lib/source_monitor/fetching/feed_fetcher/entry_processor.rb +89 -0
  165. data/lib/source_monitor/fetching/feed_fetcher/source_updater.rb +200 -0
  166. data/lib/source_monitor/fetching/feed_fetcher.rb +37 -379
  167. data/lib/source_monitor/items/item_creator/content_extractor.rb +113 -0
  168. data/lib/source_monitor/items/item_creator/entry_parser/media_extraction.rb +96 -0
  169. data/lib/source_monitor/items/item_creator/entry_parser.rb +294 -0
  170. data/lib/source_monitor/items/item_creator.rb +28 -455
  171. data/lib/source_monitor/setup/bundle_installer.rb +2 -0
  172. data/lib/source_monitor/setup/cli.rb +2 -0
  173. data/lib/source_monitor/setup/dependency_checker.rb +2 -0
  174. data/lib/source_monitor/setup/detectors.rb +2 -0
  175. data/lib/source_monitor/setup/gemfile_editor.rb +2 -0
  176. data/lib/source_monitor/setup/initializer_patcher.rb +2 -0
  177. data/lib/source_monitor/setup/install_generator.rb +2 -0
  178. data/lib/source_monitor/setup/migration_installer.rb +2 -0
  179. data/lib/source_monitor/setup/node_installer.rb +2 -0
  180. data/lib/source_monitor/setup/prompter.rb +2 -0
  181. data/lib/source_monitor/setup/requirements.rb +2 -0
  182. data/lib/source_monitor/setup/shell_runner.rb +2 -0
  183. data/lib/source_monitor/setup/verification/action_cable_verifier.rb +2 -0
  184. data/lib/source_monitor/setup/verification/printer.rb +2 -0
  185. data/lib/source_monitor/setup/verification/result.rb +2 -0
  186. data/lib/source_monitor/setup/verification/runner.rb +2 -0
  187. data/lib/source_monitor/setup/verification/solid_queue_verifier.rb +2 -0
  188. data/lib/source_monitor/setup/verification/telemetry_logger.rb +2 -0
  189. data/lib/source_monitor/setup/workflow.rb +2 -0
  190. data/lib/source_monitor/version.rb +3 -1
  191. data/lib/source_monitor.rb +140 -58
  192. data/lib/tasks/source_monitor_assets.rake +2 -0
  193. data/lib/tasks/source_monitor_setup.rake +2 -0
  194. data/lib/tasks/source_monitor_tasks.rake +2 -0
  195. data/source_monitor.gemspec +3 -1
  196. metadata +144 -4
@@ -7,21 +7,18 @@ require "source_monitor/sources/params"
7
7
 
8
8
  module SourceMonitor
9
9
  class ImportSessionsController < ApplicationController
10
+ include SourceMonitor::ImportSessions::OpmlParser
11
+ include SourceMonitor::ImportSessions::EntryAnnotation
12
+ include SourceMonitor::ImportSessions::HealthCheckManagement
13
+ include SourceMonitor::ImportSessions::BulkConfiguration
14
+
10
15
  before_action :ensure_current_user!
11
16
  before_action :set_import_session, only: %i[show update destroy]
12
17
  before_action :authorize_import_session!, only: %i[show update destroy]
13
18
  before_action :set_wizard_step, only: %i[show update]
14
19
 
15
- ALLOWED_CONTENT_TYPES = %w[text/xml application/xml text/x-opml application/opml].freeze
16
- GENERIC_CONTENT_TYPES = %w[application/octet-stream binary/octet-stream].freeze
17
-
18
20
  def new
19
- import_session = ImportSession.create!(
20
- user_id: current_user_id,
21
- current_step: ImportSession.default_step
22
- )
23
-
24
- redirect_to source_monitor.step_import_session_path(import_session, step: import_session.current_step)
21
+ create
25
22
  end
26
23
 
27
24
  def create
@@ -82,7 +79,6 @@ module SourceMonitor
82
79
  def handle_health_check_step
83
80
  @selected_source_ids = health_check_selection_from_params
84
81
  @import_session.update!(selected_source_ids: @selected_source_ids)
85
-
86
82
  if advancing_from_health_check? && @selected_source_ids.blank?
87
83
  @selection_error = "Select at least one source to continue."
88
84
  prepare_health_check_context
@@ -93,22 +89,12 @@ module SourceMonitor
93
89
  @current_step = target_step
94
90
  deactivate_health_checks! if @current_step != "health_check"
95
91
  @import_session.update_column(:current_step, @current_step) if @import_session.current_step != @current_step
96
-
97
92
  prepare_health_check_context if @current_step == "health_check"
98
-
99
93
  redirect_to source_monitor.step_import_session_path(@import_session, step: @current_step), allow_other_host: false
100
94
  end
101
95
 
102
- def session_attributes
103
- attrs = state_params.except(:next_step, :current_step, "next_step", "current_step")
104
- attrs[:opml_file_metadata] = build_file_metadata if uploading_file?
105
- attrs[:current_step] = target_step
106
- attrs
107
- end
108
-
109
96
  def handle_upload_step
110
97
  @upload_errors = validate_upload!
111
-
112
98
  if @upload_errors.any?
113
99
  render :show, status: :unprocessable_entity
114
100
  return
@@ -116,7 +102,6 @@ module SourceMonitor
116
102
 
117
103
  parsed_entries = parse_opml_file(params[:opml_file])
118
104
  valid_entries = parsed_entries.select { |entry| entry[:status] == "valid" }
119
-
120
105
  if valid_entries.empty?
121
106
  @upload_errors = [ "We couldn't find any valid feeds in that OPML file. Check the file and try again." ]
122
107
  @import_session.update!(opml_file_metadata: build_file_metadata, parsed_sources: parsed_entries, current_step: "upload")
@@ -203,24 +188,19 @@ module SourceMonitor
203
188
  def handle_confirm_step
204
189
  @selected_source_ids = Array(@import_session.selected_source_ids).map(&:to_s)
205
190
  @selected_entries = annotated_entries(@selected_source_ids).select { |entry| @selected_source_ids.include?(entry[:id]) }
206
-
207
191
  if @selected_entries.empty?
208
192
  @selection_error = "Select at least one source to import."
209
193
  prepare_confirm_context
210
194
  render :show, status: :unprocessable_entity
211
195
  return
212
196
  end
213
-
214
197
  history = SourceMonitor::ImportHistory.create!(
215
198
  user_id: @import_session.user_id,
216
199
  bulk_settings: @import_session.bulk_settings
217
200
  )
218
-
219
201
  SourceMonitor::ImportOpmlJob.perform_later(@import_session.id, history.id)
220
202
  @import_session.update_column(:current_step, "confirm") if @import_session.current_step != "confirm"
221
-
222
203
  message = "Import started for #{@selected_entries.size} sources."
223
-
224
204
  respond_to do |format|
225
205
  format.turbo_stream do
226
206
  responder = SourceMonitor::TurboStreams::StreamResponder.new
@@ -235,149 +215,6 @@ module SourceMonitor
235
215
  end
236
216
  end
237
217
 
238
- def state_params
239
- @state_params ||= begin
240
- permitted = params.fetch(:import_session, {}).permit(
241
- :current_step,
242
- :next_step,
243
- :select_all,
244
- :select_none,
245
- parsed_sources: [],
246
- selected_source_ids: [],
247
- bulk_settings: {},
248
- opml_file_metadata: {}
249
- )
250
-
251
- SourceMonitor::Security::ParameterSanitizer.sanitize(permitted.to_h)
252
- end
253
- end
254
-
255
- def build_file_metadata
256
- return {} unless params[:opml_file].respond_to?(:original_filename)
257
-
258
- file = params[:opml_file]
259
- {
260
- "filename" => file.original_filename,
261
- "byte_size" => file.size,
262
- "content_type" => file.content_type
263
- }
264
- end
265
-
266
- def uploading_file?
267
- params[:opml_file].present?
268
- end
269
-
270
- def permitted_step(value)
271
- step = value.to_s.presence
272
- return unless step
273
-
274
- ImportSession::STEP_ORDER.find { |candidate| candidate == step }
275
- end
276
-
277
- def target_step
278
- next_step = state_params[:next_step] || state_params["next_step"]
279
- permitted_step(next_step) || @current_step || ImportSession.default_step
280
- end
281
-
282
- def validate_upload!
283
- return [ "Upload an OPML file to continue." ] unless uploading_file?
284
-
285
- file = params[:opml_file]
286
- errors = []
287
-
288
- errors << "The uploaded file is empty. Choose another OPML file." if file.size.to_i <= 0
289
-
290
- if file.content_type.present? && !content_type_allowed?(file.content_type) && !generic_content_type?(file.content_type)
291
- errors << "Upload must be an OPML or XML file."
292
- end
293
-
294
- errors
295
- end
296
-
297
- def content_type_allowed?(content_type)
298
- ALLOWED_CONTENT_TYPES.include?(content_type)
299
- end
300
-
301
- def generic_content_type?(content_type)
302
- GENERIC_CONTENT_TYPES.include?(content_type)
303
- end
304
-
305
- def parse_opml_file(file)
306
- content = file.read
307
- file.rewind if file.respond_to?(:rewind)
308
-
309
- raise UploadError, "The uploaded file appears to be empty." if content.blank?
310
-
311
- document = Nokogiri::XML(content) { |config| config.strict.nonet }
312
- raise UploadError, "The uploaded file is not valid XML or OPML." if document.root.nil?
313
-
314
- outlines = document.xpath("//outline")
315
-
316
- entries = []
317
-
318
- outlines.each_with_index do |outline, index|
319
- next unless outline.attribute_nodes.any? { |attr| attr.name.casecmp("xmlurl").zero? }
320
-
321
- entries << build_entry(outline, index)
322
- end
323
-
324
- entries
325
- rescue Nokogiri::XML::SyntaxError => error
326
- raise UploadError, "We couldn't parse that OPML file: #{error.message}"
327
- end
328
-
329
- def build_entry(outline, index)
330
- feed_url = outline_attribute(outline, "xmlUrl")
331
- website_url = outline_attribute(outline, "htmlUrl")
332
- title = outline_attribute(outline, "title") || outline_attribute(outline, "text")
333
-
334
- if feed_url.blank?
335
- return malformed_entry(index, feed_url, title, website_url, "Missing feed URL")
336
- end
337
-
338
- unless valid_feed_url?(feed_url)
339
- return malformed_entry(index, feed_url, title, website_url, "Feed URL must be HTTP or HTTPS")
340
- end
341
-
342
- {
343
- id: "outline-#{index}",
344
- raw_outline_index: index,
345
- feed_url: feed_url,
346
- title: title,
347
- website_url: website_url,
348
- status: "valid",
349
- error: nil,
350
- health_status: nil,
351
- health_error: nil
352
- }
353
- end
354
-
355
- def malformed_entry(index, feed_url, title, website_url, error)
356
- {
357
- id: "outline-#{index}",
358
- raw_outline_index: index,
359
- feed_url: feed_url.presence,
360
- title: title,
361
- website_url: website_url,
362
- status: "malformed",
363
- error: error,
364
- health_status: nil,
365
- health_error: nil
366
- }
367
- end
368
-
369
- def outline_attribute(outline, name)
370
- attribute = outline.attribute_nodes.find { |attr| attr.name.casecmp(name).zero? }
371
- attribute&.value.to_s.presence
372
- end
373
-
374
- def valid_feed_url?(url)
375
- parsed = URI.parse(url)
376
- parsed.is_a?(URI::HTTP) && parsed.host.present?
377
- rescue URI::InvalidURIError
378
- false
379
- end
380
-
381
218
  # :nocov: These methods provide unauthenticated fallback behavior for
382
219
  # environments where the host app has no user model configured. They are
383
220
  # exercised in smoke testing but excluded from diff coverage because they
@@ -449,343 +286,5 @@ module SourceMonitor
449
286
 
450
287
  head :forbidden unless @import_session.user_id == current_user_id
451
288
  end
452
-
453
- def prepare_preview_context(skip_default: false)
454
- @filter = permitted_filter(params[:filter]) || "all"
455
- @page = normalize_page_param(params[:page])
456
- @selected_source_ids = Array(@import_session.selected_source_ids).map(&:to_s)
457
-
458
- @preview_entries = annotated_entries(@selected_source_ids)
459
-
460
- if !skip_default && @selected_source_ids.blank? && @preview_entries.present?
461
- defaults = selectable_entries_from(@preview_entries).map { |entry| entry[:id] }
462
- @selected_source_ids = defaults
463
- @import_session.update_column(:selected_source_ids, defaults)
464
- @preview_entries = annotated_entries(@selected_source_ids)
465
- end
466
-
467
- @filtered_entries = filter_entries(@preview_entries, @filter)
468
-
469
- paginator = SourceMonitor::Pagination::Paginator.new(
470
- scope: @filtered_entries,
471
- page: @page,
472
- per_page: preview_per_page
473
- ).paginate
474
-
475
- @paginated_entries = paginator.records
476
- @has_next_page = paginator.has_next_page
477
- @has_previous_page = paginator.has_previous_page
478
- @page = paginator.page
479
- end
480
-
481
- def prepare_health_check_context
482
- start_health_checks_if_needed
483
-
484
- @selected_source_ids = Array(@import_session.selected_source_ids).map(&:to_s)
485
- @health_check_entries = health_check_entries(@selected_source_ids)
486
- @health_check_target_ids = health_check_targets
487
- @health_progress = health_check_progress(@health_check_entries)
488
- end
489
-
490
- def prepare_configure_context
491
- @bulk_source = build_bulk_source_from_session
492
- end
493
-
494
- def prepare_confirm_context
495
- @selected_source_ids = Array(@import_session.selected_source_ids).map(&:to_s)
496
- @selected_entries = annotated_entries(@selected_source_ids)
497
- .select { |entry| @selected_source_ids.include?(entry[:id]) }
498
- @bulk_settings = @import_session.bulk_settings || {}
499
- end
500
-
501
- def annotated_entries(selected_ids)
502
- selected_ids ||= []
503
- entries = Array(@import_session.parsed_sources)
504
- return [] if entries.blank?
505
-
506
- normalized = entries.map { |entry| normalize_entry(entry) }
507
-
508
- feed_urls = normalized.filter_map { |entry| entry[:feed_url]&.downcase }
509
- duplicate_lookup = if feed_urls.present?
510
- SourceMonitor::Source.where("LOWER(feed_url) IN (?)", feed_urls).pluck(:feed_url).map(&:downcase)
511
- else
512
- []
513
- end
514
-
515
- normalized.map do |entry|
516
- duplicate = entry[:feed_url].present? && duplicate_lookup.include?(entry[:feed_url].downcase)
517
- entry.merge(
518
- duplicate: duplicate,
519
- selectable: entry[:status] == "valid" && !duplicate,
520
- selected: selected_ids.include?(entry[:id])
521
- )
522
- end
523
- end
524
-
525
- def health_check_entries(selected_ids)
526
- targets = health_check_targets
527
- entries = Array(@import_session.parsed_sources).map { |entry| normalize_entry(entry) }
528
-
529
- entries.select { |entry| targets.include?(entry[:id]) }.map do |entry|
530
- entry.merge(selected: selected_ids.include?(entry[:id]))
531
- end
532
- end
533
-
534
- def health_check_progress(entries)
535
- total = health_check_targets.size
536
- completed = entries.count { |entry| health_check_complete?(entry) }
537
-
538
- {
539
- completed: completed,
540
- total: total,
541
- pending: [ total - completed, 0 ].max,
542
- active: @import_session.health_checks_active?,
543
- done: total.positive? && completed >= total
544
- }
545
- end
546
-
547
- def health_check_complete?(entry)
548
- %w[healthy unhealthy].include?(entry[:health_status].to_s)
549
- end
550
-
551
- def health_check_targets
552
- targets = @import_session.health_check_targets
553
- targets = Array(@import_session.selected_source_ids).map(&:to_s) if targets.blank?
554
- targets
555
- end
556
-
557
- def selectable_entries_from(entries)
558
- entries.select { |entry| entry[:selectable] }
559
- end
560
-
561
- def normalize_entry(entry)
562
- entry = entry.to_h
563
- SourceMonitor::ImportSessions::EntryNormalizer.normalize(entry)
564
- end
565
-
566
- def filter_entries(entries, filter)
567
- case filter
568
- when "new"
569
- entries.select { |entry| entry[:selectable] }
570
- when "existing"
571
- entries.select { |entry| entry[:duplicate] }
572
- else
573
- entries
574
- end
575
- end
576
-
577
- def build_selection_from_params
578
- @selected_source_ids ||= []
579
-
580
- if params.dig(:import_session, :select_all) == "true"
581
- return selectable_entries.map { |entry| entry[:id] }
582
- end
583
-
584
- if params.dig(:import_session, :select_none) == "true"
585
- return []
586
- end
587
-
588
- ids = params.dig(:import_session, :selected_source_ids)
589
- return [] unless ids
590
-
591
- Array(ids).map { |id| id.to_s }.uniq
592
- end
593
-
594
- def health_check_selection_from_params
595
- if params.dig(:import_session, :select_all) == "true"
596
- return health_check_targets.dup
597
- end
598
-
599
- return [] if params.dig(:import_session, :select_none) == "true"
600
-
601
- ids = params.dig(:import_session, :selected_source_ids)
602
- return Array(@import_session.selected_source_ids).map(&:to_s) unless ids
603
-
604
- Array(ids).map { |id| id.to_s }.uniq & health_check_targets
605
- end
606
-
607
- def selectable_entries
608
- @selectable_entries ||= annotated_entries(@selected_source_ids).select { |entry| entry[:selectable] }
609
- end
610
-
611
- def advancing_from_health_check?
612
- target_step != "health_check"
613
- end
614
-
615
- def advancing_from_preview?
616
- target_step != "preview"
617
- end
618
-
619
- def normalize_page_param(value)
620
- number = value.to_i
621
- number = 1 if number <= 0
622
- number
623
- rescue StandardError
624
- 1
625
- end
626
-
627
- def start_health_checks_if_needed
628
- return unless @current_step == "health_check"
629
-
630
- jobs_to_enqueue = []
631
-
632
- @import_session.with_lock do
633
- @import_session.reload
634
- selected = Array(@import_session.selected_source_ids).map(&:to_s)
635
-
636
- if selected.blank?
637
- @import_session.update_columns(health_checks_active: false, health_check_target_ids: [])
638
- next
639
- end
640
-
641
- if @import_session.health_checks_active? && @import_session.health_check_targets.sort == selected.sort
642
- @health_check_target_ids = @import_session.health_check_targets
643
- next
644
- end
645
-
646
- updated_entries = reset_health_results(@import_session.parsed_sources, selected)
647
- @import_session.update!(
648
- parsed_sources: updated_entries,
649
- health_checks_active: true,
650
- health_check_target_ids: selected,
651
- health_check_started_at: Time.current,
652
- health_check_completed_at: nil
653
- )
654
-
655
- @health_check_target_ids = selected
656
- jobs_to_enqueue = selected
657
- end
658
-
659
- enqueue_health_check_jobs(@import_session, jobs_to_enqueue) if jobs_to_enqueue.any?
660
- end
661
-
662
- def build_bulk_source_from_session
663
- settings = @import_session.bulk_settings.presence || {}
664
- build_bulk_source(settings)
665
- end
666
-
667
- def build_bulk_source_from_params
668
- settings = configure_source_params
669
- settings = strip_identity_attributes(settings) if settings
670
- settings ||= @import_session.bulk_settings.presence || {}
671
-
672
- build_bulk_source(settings)
673
- end
674
-
675
- def build_bulk_source(settings)
676
- sample_identity = sample_identity_attributes
677
- defaults = SourceMonitor::Sources::Params.default_attributes
678
-
679
- source = SourceMonitor::Source.new(defaults.merge(sample_identity))
680
- source.assign_attributes(settings.deep_symbolize_keys) if settings.present?
681
- source
682
- end
683
-
684
- def sample_identity_attributes
685
- entry = selected_entries_for_identity.first
686
- return fallback_identity unless entry
687
-
688
- normalized = normalize_entry(entry)
689
- {
690
- name: normalized[:title].presence || normalized[:feed_url] || fallback_identity[:name],
691
- feed_url: normalized[:feed_url].presence || fallback_identity[:feed_url],
692
- website_url: normalized[:website_url]
693
- }
694
- end
695
-
696
- def selected_entries_for_identity
697
- targets = Array(@import_session.selected_source_ids).map(&:to_s)
698
- entries = Array(@import_session.parsed_sources)
699
- return entries if targets.blank?
700
-
701
- entries.select { |entry| targets.include?(entry.to_h.fetch("id", entry[:id]).to_s) }
702
- end
703
-
704
- def fallback_identity
705
- {
706
- name: "Imported source",
707
- feed_url: "https://example.com/feed.xml"
708
- }
709
- end
710
-
711
- def configure_source_params
712
- return unless params[:source].present?
713
-
714
- SourceMonitor::Sources::Params.sanitize(params)
715
- end
716
-
717
- def strip_identity_attributes(settings)
718
- settings.with_indifferent_access.except(:name, :feed_url, :website_url)
719
- end
720
-
721
- def persist_bulk_settings_if_valid!
722
- settings = configure_source_params
723
- return unless settings
724
- return unless @bulk_source.valid?
725
-
726
- @import_session.update!(bulk_settings: bulk_settings_payload(@bulk_source))
727
- end
728
-
729
- def bulk_settings_payload(source)
730
- payload = source.attributes.slice(*bulk_setting_keys)
731
- payload["scrape_settings"] = source.scrape_settings
732
-
733
- SourceMonitor::Security::ParameterSanitizer.sanitize(payload)
734
- end
735
-
736
- def bulk_setting_keys
737
- %w[
738
- fetch_interval_minutes
739
- active
740
- auto_scrape
741
- scraping_enabled
742
- requires_javascript
743
- feed_content_readability_enabled
744
- scraper_adapter
745
- items_retention_days
746
- max_items
747
- adaptive_fetching_enabled
748
- health_auto_pause_threshold
749
- scrape_settings
750
- ]
751
- end
752
-
753
- def reset_health_results(entries, target_ids)
754
- Array(entries).map do |entry|
755
- entry_hash = entry.to_h
756
- entry_id = entry_hash["id"] || entry_hash[:id]
757
- next entry_hash unless target_ids.include?(entry_id.to_s)
758
-
759
- entry_hash.merge("health_status" => "pending", "health_error" => nil)
760
- end
761
- end
762
-
763
- def enqueue_health_check_jobs(import_session, target_ids)
764
- target_ids.each do |target_id|
765
- SourceMonitor::ImportSessionHealthCheckJob.set(wait: 1.second).perform_later(import_session.id, target_id)
766
- end
767
- end
768
-
769
- def deactivate_health_checks!
770
- return unless @import_session.health_checks_active?
771
-
772
- @import_session.update_columns(
773
- health_checks_active: false,
774
- health_check_completed_at: Time.current
775
- )
776
- end
777
-
778
- def permitted_filter(raw)
779
- value = raw.to_s.presence
780
- return unless value
781
-
782
- %w[all new existing].find { |candidate| candidate == value }
783
- end
784
-
785
- def preview_per_page
786
- 25
787
- end
788
-
789
- class UploadError < StandardError; end
790
289
  end
791
290
  end
@@ -36,6 +36,8 @@ module SourceMonitor
36
36
  def show
37
37
  end
38
38
 
39
+ # TODO: Extract to ItemScrapesController (CRUD-only convention).
40
+ # Deferred to avoid view/route churn in a cleanup phase.
39
41
  def scrape
40
42
  log_manual_scrape("controller:start", item: @item, extra: { format: request.format })
41
43
 
@@ -110,20 +110,6 @@ module SourceMonitor
110
110
  end
111
111
  end
112
112
 
113
- def fetch
114
- SourceMonitor::Fetching::FetchRunner.enqueue(@source.id)
115
- render_fetch_enqueue_response("Fetch has been enqueued and will run shortly.")
116
- rescue StandardError => error
117
- handle_fetch_failure(error)
118
- end
119
-
120
- def retry
121
- SourceMonitor::Fetching::FetchRunner.enqueue(@source.id, force: true)
122
- render_fetch_enqueue_response("Retry has been forced and will run shortly.")
123
- rescue StandardError => error
124
- handle_fetch_failure(error)
125
- end
126
-
127
113
  private
128
114
 
129
115
  def set_source