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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/Gemfile.lock +1 -1
- data/app/assets/javascripts/source_monitor/application.js +4 -0
- data/app/assets/javascripts/source_monitor/controllers/confirm_navigation_controller.js +49 -0
- data/app/assets/javascripts/source_monitor/controllers/select_all_controller.js +36 -0
- data/app/controllers/source_monitor/import_sessions_controller.rb +791 -0
- data/app/controllers/source_monitor/sources_controller.rb +5 -36
- data/app/helpers/source_monitor/application_helper.rb +17 -0
- data/app/jobs/source_monitor/import_opml_job.rb +150 -0
- data/app/jobs/source_monitor/import_session_health_check_job.rb +93 -0
- data/app/models/source_monitor/import_history.rb +35 -0
- data/app/models/source_monitor/import_session.rb +34 -0
- data/app/views/source_monitor/import_sessions/_header.html.erb +12 -0
- data/app/views/source_monitor/import_sessions/_sidebar.html.erb +23 -0
- data/app/views/source_monitor/import_sessions/health_check/_progress.html.erb +20 -0
- data/app/views/source_monitor/import_sessions/health_check/_row.html.erb +44 -0
- data/app/views/source_monitor/import_sessions/show.html.erb +15 -0
- data/app/views/source_monitor/import_sessions/show.turbo_stream.erb +1 -0
- data/app/views/source_monitor/import_sessions/steps/_configure.html.erb +53 -0
- data/app/views/source_monitor/import_sessions/steps/_confirm.html.erb +121 -0
- data/app/views/source_monitor/import_sessions/steps/_health_check.html.erb +82 -0
- data/app/views/source_monitor/import_sessions/steps/_navigation.html.erb +29 -0
- data/app/views/source_monitor/import_sessions/steps/_preview.html.erb +172 -0
- data/app/views/source_monitor/import_sessions/steps/_upload.html.erb +42 -0
- data/app/views/source_monitor/sources/_form.html.erb +8 -138
- data/app/views/source_monitor/sources/_form_fields.html.erb +142 -0
- data/app/views/source_monitor/sources/_import_history_panel.html.erb +53 -0
- data/app/views/source_monitor/sources/index.html.erb +7 -1
- data/config/coverage_baseline.json +91 -15
- data/config/routes.rb +6 -0
- data/db/migrate/20251124090000_create_import_sessions.rb +18 -0
- data/db/migrate/20251124153000_add_health_fields_to_import_sessions.rb +14 -0
- data/db/migrate/20251125094500_create_import_histories.rb +19 -0
- data/lib/source_monitor/health/import_source_health_check.rb +55 -0
- data/lib/source_monitor/health.rb +1 -0
- data/lib/source_monitor/import_sessions/entry_normalizer.rb +30 -0
- data/lib/source_monitor/import_sessions/health_check_broadcaster.rb +103 -0
- data/lib/source_monitor/sources/params.rb +52 -0
- data/lib/source_monitor/version.rb +1 -1
- data/tasks/completed/codebase_audit_2025.md +1396 -0
- data/tasks/completed/engine-asset-configuration.md +203 -0
- data/tasks/completed/opml-import-wizard/opml-import-wizard-product-brief.md +58 -0
- data/tasks/completed/opml-import-wizard/opml-import-wizard-tech-brief.md +75 -0
- data/tasks/completed/opml-import-wizard/task-01/instructions.md +81 -0
- data/tasks/completed/opml-import-wizard/task-01/requirements.md +19 -0
- data/tasks/completed/opml-import-wizard/task-02/instructions.md +83 -0
- data/tasks/completed/opml-import-wizard/task-02/requirements.md +18 -0
- data/tasks/completed/opml-import-wizard/task-03/instructions.md +58 -0
- data/tasks/completed/opml-import-wizard/task-03/requirements.md +18 -0
- data/tasks/completed/opml-import-wizard/task-04/instructions.md +84 -0
- data/tasks/completed/opml-import-wizard/task-04/requirements.md +17 -0
- data/tasks/completed/opml-import-wizard/task-05/instructions.md +50 -0
- data/tasks/completed/opml-import-wizard/task-05/requirements.md +17 -0
- data/tasks/completed/opml-import-wizard/task-06/instructions.md +92 -0
- data/tasks/completed/opml-import-wizard/task-06/requirements.md +21 -0
- data/tasks/completed/phase_17_01_complexity_audit_2025-10-12.md +62 -0
- data/tasks/completed/phase_17_02_complexity_findings_2025-10-12.md +74 -0
- data/tasks/completed/phase_17_03_refactor_plan_2025-10-12.md +37 -0
- data/tasks/completed/phase_21_01_log_consolidation_2025-10-15.md +30 -0
- data/tasks/completed/release_checklist.md +23 -0
- data/tasks/completed/routes_refactor_evaluation.md +109 -0
- data/tasks/completed/source_monitor_rename_plan.md +70 -0
- data/tasks/completed/tasks.md +952 -0
- data/tasks/ideas.md +10 -0
- metadata +56 -3
- /data/tasks/{prd-setup-workflow-streamlining.md → completed/prd-setup-workflow-streamlining.md} +0 -0
- /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
|