source_monitor 0.1.3 → 0.2.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/Gemfile.lock +1 -1
  4. data/app/assets/javascripts/source_monitor/application.js +4 -0
  5. data/app/assets/javascripts/source_monitor/controllers/confirm_navigation_controller.js +49 -0
  6. data/app/assets/javascripts/source_monitor/controllers/select_all_controller.js +36 -0
  7. data/app/controllers/source_monitor/import_sessions_controller.rb +791 -0
  8. data/app/controllers/source_monitor/sources_controller.rb +5 -36
  9. data/app/helpers/source_monitor/application_helper.rb +17 -0
  10. data/app/jobs/source_monitor/import_opml_job.rb +150 -0
  11. data/app/jobs/source_monitor/import_session_health_check_job.rb +93 -0
  12. data/app/models/source_monitor/import_history.rb +35 -0
  13. data/app/models/source_monitor/import_session.rb +34 -0
  14. data/app/views/source_monitor/import_sessions/_header.html.erb +12 -0
  15. data/app/views/source_monitor/import_sessions/_sidebar.html.erb +23 -0
  16. data/app/views/source_monitor/import_sessions/health_check/_progress.html.erb +20 -0
  17. data/app/views/source_monitor/import_sessions/health_check/_row.html.erb +44 -0
  18. data/app/views/source_monitor/import_sessions/show.html.erb +15 -0
  19. data/app/views/source_monitor/import_sessions/show.turbo_stream.erb +1 -0
  20. data/app/views/source_monitor/import_sessions/steps/_configure.html.erb +53 -0
  21. data/app/views/source_monitor/import_sessions/steps/_confirm.html.erb +121 -0
  22. data/app/views/source_monitor/import_sessions/steps/_health_check.html.erb +82 -0
  23. data/app/views/source_monitor/import_sessions/steps/_navigation.html.erb +29 -0
  24. data/app/views/source_monitor/import_sessions/steps/_preview.html.erb +172 -0
  25. data/app/views/source_monitor/import_sessions/steps/_upload.html.erb +42 -0
  26. data/app/views/source_monitor/sources/_form.html.erb +8 -138
  27. data/app/views/source_monitor/sources/_form_fields.html.erb +142 -0
  28. data/app/views/source_monitor/sources/_import_history_panel.html.erb +53 -0
  29. data/app/views/source_monitor/sources/index.html.erb +7 -1
  30. data/config/coverage_baseline.json +91 -15
  31. data/config/routes.rb +6 -0
  32. data/db/migrate/20251124090000_create_import_sessions.rb +18 -0
  33. data/db/migrate/20251124153000_add_health_fields_to_import_sessions.rb +14 -0
  34. data/db/migrate/20251125094500_create_import_histories.rb +19 -0
  35. data/lib/source_monitor/health/import_source_health_check.rb +55 -0
  36. data/lib/source_monitor/health.rb +1 -0
  37. data/lib/source_monitor/import_sessions/entry_normalizer.rb +30 -0
  38. data/lib/source_monitor/import_sessions/health_check_broadcaster.rb +103 -0
  39. data/lib/source_monitor/sources/params.rb +52 -0
  40. data/lib/source_monitor/version.rb +1 -1
  41. data/tasks/completed/codebase_audit_2025.md +1396 -0
  42. data/tasks/completed/engine-asset-configuration.md +203 -0
  43. data/tasks/completed/opml-import-wizard/opml-import-wizard-product-brief.md +58 -0
  44. data/tasks/completed/opml-import-wizard/opml-import-wizard-tech-brief.md +75 -0
  45. data/tasks/completed/opml-import-wizard/task-01/instructions.md +81 -0
  46. data/tasks/completed/opml-import-wizard/task-01/requirements.md +19 -0
  47. data/tasks/completed/opml-import-wizard/task-02/instructions.md +83 -0
  48. data/tasks/completed/opml-import-wizard/task-02/requirements.md +18 -0
  49. data/tasks/completed/opml-import-wizard/task-03/instructions.md +58 -0
  50. data/tasks/completed/opml-import-wizard/task-03/requirements.md +18 -0
  51. data/tasks/completed/opml-import-wizard/task-04/instructions.md +84 -0
  52. data/tasks/completed/opml-import-wizard/task-04/requirements.md +17 -0
  53. data/tasks/completed/opml-import-wizard/task-05/instructions.md +50 -0
  54. data/tasks/completed/opml-import-wizard/task-05/requirements.md +17 -0
  55. data/tasks/completed/opml-import-wizard/task-06/instructions.md +92 -0
  56. data/tasks/completed/opml-import-wizard/task-06/requirements.md +21 -0
  57. data/tasks/completed/phase_17_01_complexity_audit_2025-10-12.md +62 -0
  58. data/tasks/completed/phase_17_02_complexity_findings_2025-10-12.md +74 -0
  59. data/tasks/completed/phase_17_03_refactor_plan_2025-10-12.md +37 -0
  60. data/tasks/completed/phase_21_01_log_consolidation_2025-10-15.md +30 -0
  61. data/tasks/completed/release_checklist.md +23 -0
  62. data/tasks/completed/routes_refactor_evaluation.md +109 -0
  63. data/tasks/completed/source_monitor_rename_plan.md +70 -0
  64. data/tasks/completed/tasks.md +952 -0
  65. data/tasks/ideas.md +10 -0
  66. metadata +56 -3
  67. /data/tasks/{prd-setup-workflow-streamlining.md → completed/prd-setup-workflow-streamlining.md} +0 -0
  68. /data/tasks/{tasks-setup-workflow-streamlining.md → completed/tasks-setup-workflow-streamlining.md} +0 -0
@@ -0,0 +1,791 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+ require "uri"
5
+ require "source_monitor/import_sessions/entry_normalizer"
6
+ require "source_monitor/sources/params"
7
+
8
+ module SourceMonitor
9
+ class ImportSessionsController < ApplicationController
10
+ before_action :ensure_current_user!
11
+ before_action :set_import_session, only: %i[show update destroy]
12
+ before_action :authorize_import_session!, only: %i[show update destroy]
13
+ before_action :set_wizard_step, only: %i[show update]
14
+
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
+ 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)
25
+ end
26
+
27
+ def create
28
+ import_session = ImportSession.create!(
29
+ user_id: current_user_id,
30
+ current_step: ImportSession.default_step
31
+ )
32
+
33
+ redirect_to source_monitor.step_import_session_path(import_session, step: import_session.current_step)
34
+ end
35
+
36
+ def show
37
+ prepare_preview_context if @current_step == "preview"
38
+ prepare_health_check_context if @current_step == "health_check"
39
+ prepare_configure_context if @current_step == "configure"
40
+ prepare_confirm_context if @current_step == "confirm"
41
+ persist_step!
42
+ render :show
43
+ end
44
+
45
+ def update
46
+ return handle_upload_step if @current_step == "upload"
47
+ return handle_preview_step if @current_step == "preview"
48
+ return handle_health_check_step if @current_step == "health_check"
49
+ return handle_configure_step if @current_step == "configure"
50
+ return handle_confirm_step if @current_step == "confirm"
51
+
52
+ @import_session.update!(session_attributes)
53
+ @current_step = target_step
54
+ @import_session.update_column(:current_step, @current_step) if @import_session.current_step != @current_step
55
+
56
+ redirect_to source_monitor.step_import_session_path(@import_session, step: @current_step), allow_other_host: false
57
+ end
58
+
59
+ def destroy
60
+ @import_session.destroy
61
+ redirect_to source_monitor.sources_path, notice: "Import canceled"
62
+ end
63
+
64
+ private
65
+
66
+ def set_import_session
67
+ @import_session = ImportSession.find(params[:id])
68
+ end
69
+
70
+ def set_wizard_step
71
+ @wizard_steps = ImportSession::STEP_ORDER
72
+ @current_step = permitted_step(params[:step]) || @import_session.current_step || ImportSession.default_step
73
+ end
74
+
75
+ def persist_step!
76
+ return if @import_session.current_step == @current_step
77
+
78
+ deactivate_health_checks! if @current_step != "health_check"
79
+ @import_session.update_column(:current_step, @current_step)
80
+ end
81
+
82
+ def handle_health_check_step
83
+ @selected_source_ids = health_check_selection_from_params
84
+ @import_session.update!(selected_source_ids: @selected_source_ids)
85
+
86
+ if advancing_from_health_check? && @selected_source_ids.blank?
87
+ @selection_error = "Select at least one source to continue."
88
+ prepare_health_check_context
89
+ render :show, status: :unprocessable_entity
90
+ return
91
+ end
92
+
93
+ @current_step = target_step
94
+ deactivate_health_checks! if @current_step != "health_check"
95
+ @import_session.update_column(:current_step, @current_step) if @import_session.current_step != @current_step
96
+
97
+ prepare_health_check_context if @current_step == "health_check"
98
+
99
+ redirect_to source_monitor.step_import_session_path(@import_session, step: @current_step), allow_other_host: false
100
+ end
101
+
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
+ def handle_upload_step
110
+ @upload_errors = validate_upload!
111
+
112
+ if @upload_errors.any?
113
+ render :show, status: :unprocessable_entity
114
+ return
115
+ end
116
+
117
+ parsed_entries = parse_opml_file(params[:opml_file])
118
+ valid_entries = parsed_entries.select { |entry| entry[:status] == "valid" }
119
+
120
+ if valid_entries.empty?
121
+ @upload_errors = [ "We couldn't find any valid feeds in that OPML file. Check the file and try again." ]
122
+ @import_session.update!(opml_file_metadata: build_file_metadata, parsed_sources: parsed_entries, current_step: "upload")
123
+ render :show, status: :unprocessable_entity
124
+ return
125
+ end
126
+
127
+ @import_session.update!(
128
+ opml_file_metadata: build_file_metadata.merge("uploaded_at" => Time.current),
129
+ parsed_sources: parsed_entries,
130
+ current_step: target_step
131
+ )
132
+
133
+ @current_step = target_step
134
+ prepare_preview_context(skip_default: true) if @current_step == "preview"
135
+
136
+ respond_to do |format|
137
+ format.turbo_stream { render :show }
138
+ format.html { redirect_to source_monitor.step_import_session_path(@import_session, step: @current_step) }
139
+ end
140
+ rescue UploadError => error
141
+ @upload_errors = [ error.message ]
142
+ render :show, status: :unprocessable_entity
143
+ end
144
+
145
+ def handle_preview_step
146
+ @selected_source_ids = Array(@import_session.selected_source_ids).map(&:to_s)
147
+
148
+ if params.dig(:import_session, :select_all).present?
149
+ @selected_source_ids = selectable_entries.map { |entry| entry[:id] }
150
+ @import_session.update_column(:selected_source_ids, @selected_source_ids)
151
+ valid_ids = @selected_source_ids
152
+ elsif params.dig(:import_session, :select_none).present?
153
+ @selected_source_ids = []
154
+ @import_session.update_column(:selected_source_ids, @selected_source_ids)
155
+ valid_ids = []
156
+ else
157
+ @selected_source_ids = build_selection_from_params
158
+ valid_ids = selectable_entries.index_by { |entry| entry[:id] }.slice(*@selected_source_ids).keys
159
+ @import_session.update!(selected_source_ids: valid_ids)
160
+ end
161
+
162
+ if advancing_from_preview? && valid_ids.empty?
163
+ @selection_error = "Select at least one new source to continue."
164
+ prepare_preview_context(skip_default: true)
165
+ render :show, status: :unprocessable_entity
166
+ return
167
+ end
168
+
169
+ @current_step = target_step
170
+ @import_session.update_column(:current_step, @current_step) if @import_session.current_step != @current_step
171
+
172
+ if @current_step == "health_check"
173
+ prepare_health_check_context
174
+ else
175
+ prepare_preview_context(skip_default: true)
176
+ end
177
+
178
+ respond_to do |format|
179
+ format.turbo_stream { render :show }
180
+ format.html { redirect_to source_monitor.step_import_session_path(@import_session, step: @current_step) }
181
+ end
182
+ end
183
+
184
+ def handle_configure_step
185
+ @bulk_source = build_bulk_source_from_params
186
+
187
+ if target_step == "confirm" && !@bulk_source.valid?
188
+ render :show, status: :unprocessable_entity
189
+ return
190
+ end
191
+
192
+ persist_bulk_settings_if_valid!
193
+
194
+ @current_step = target_step
195
+ @import_session.update_column(:current_step, @current_step) if @import_session.current_step != @current_step
196
+
197
+ respond_to do |format|
198
+ format.turbo_stream { render :show }
199
+ format.html { redirect_to source_monitor.step_import_session_path(@import_session, step: @current_step) }
200
+ end
201
+ end
202
+
203
+ def handle_confirm_step
204
+ @selected_source_ids = Array(@import_session.selected_source_ids).map(&:to_s)
205
+ @selected_entries = annotated_entries(@selected_source_ids).select { |entry| @selected_source_ids.include?(entry[:id]) }
206
+
207
+ if @selected_entries.empty?
208
+ @selection_error = "Select at least one source to import."
209
+ prepare_confirm_context
210
+ render :show, status: :unprocessable_entity
211
+ return
212
+ end
213
+
214
+ history = SourceMonitor::ImportHistory.create!(
215
+ user_id: @import_session.user_id,
216
+ bulk_settings: @import_session.bulk_settings
217
+ )
218
+
219
+ SourceMonitor::ImportOpmlJob.perform_later(@import_session.id, history.id)
220
+ @import_session.update_column(:current_step, "confirm") if @import_session.current_step != "confirm"
221
+
222
+ message = "Import started for #{@selected_entries.size} sources."
223
+
224
+ respond_to do |format|
225
+ format.turbo_stream do
226
+ responder = SourceMonitor::TurboStreams::StreamResponder.new
227
+ responder.toast(message:, level: :success)
228
+ responder.redirect(source_monitor.sources_path)
229
+ render turbo_stream: responder.render(view_context)
230
+ end
231
+
232
+ format.html do
233
+ redirect_to source_monitor.sources_path, notice: message
234
+ end
235
+ end
236
+ end
237
+
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
+ # :nocov: These methods provide unauthenticated fallback behavior for
382
+ # environments where the host app has no user model configured. They are
383
+ # exercised in smoke testing but excluded from diff coverage because they
384
+ # are defensive shims rather than core wizard logic.
385
+ def current_user_id
386
+ return source_monitor_current_user&.id if source_monitor_current_user
387
+
388
+ return fallback_user_id unless SourceMonitor::Security::Authentication.authentication_configured?
389
+
390
+ nil
391
+ end
392
+
393
+ def ensure_current_user!
394
+ head :forbidden unless current_user_id
395
+ end
396
+
397
+ def fallback_user_id
398
+ return @fallback_user_id if defined?(@fallback_user_id)
399
+
400
+ unless defined?(::User) && ::User.respond_to?(:first)
401
+ @fallback_user_id = nil
402
+ return @fallback_user_id
403
+ end
404
+
405
+ existing = ::User.first
406
+ if existing
407
+ @fallback_user_id = existing.id
408
+ return @fallback_user_id
409
+ end
410
+
411
+ @fallback_user_id = create_guest_user&.id
412
+ rescue StandardError
413
+ @fallback_user_id = nil
414
+ end
415
+
416
+ def create_guest_user
417
+ return unless defined?(::User)
418
+
419
+ attributes = {}
420
+ ::User.columns_hash.each do |name, column|
421
+ next if name == ::User.primary_key
422
+
423
+ if column.default.nil? && !column.null
424
+ attributes[name] = guest_value_for(column)
425
+ end
426
+ end
427
+
428
+ ::User.create(attributes)
429
+ end
430
+
431
+ def guest_value_for(column)
432
+ case column.type
433
+ when :string, :text
434
+ "source_monitor_guest"
435
+ when :boolean
436
+ false
437
+ when :integer
438
+ 0
439
+ when :datetime, :timestamp
440
+ Time.current
441
+ else
442
+ column.default
443
+ end
444
+ end
445
+ # :nocov:
446
+
447
+ def authorize_import_session!
448
+ return if !SourceMonitor::Security::Authentication.authentication_configured?
449
+
450
+ head :forbidden unless @import_session.user_id == current_user_id
451
+ 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
+ end
791
+ end