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.
- checksums.yaml +4 -4
- data/.claude/agents/rails-concern.md +464 -0
- data/.claude/agents/rails-controller.md +424 -0
- data/.claude/agents/rails-hotwire.md +446 -0
- data/.claude/agents/rails-implement.md +374 -0
- data/.claude/agents/rails-job.md +334 -0
- data/.claude/agents/rails-lint.md +294 -0
- data/.claude/agents/rails-mailer.md +371 -0
- data/.claude/agents/rails-migration.md +449 -0
- data/.claude/agents/rails-model.md +420 -0
- data/.claude/agents/rails-policy.md +443 -0
- data/.claude/agents/rails-presenter.md +427 -0
- data/.claude/agents/rails-query.md +412 -0
- data/.claude/agents/rails-review.md +490 -0
- data/.claude/agents/rails-service.md +458 -0
- data/.claude/agents/rails-state-records.md +465 -0
- data/.claude/agents/rails-tdd.md +314 -0
- data/.claude/agents/rails-test.md +441 -0
- data/.claude/agents/rails-view-component.md +418 -0
- data/.claude/hooks/block-secrets.sh +52 -0
- data/.claude/settings.json +85 -0
- data/.claude/skills/action-cable-patterns/SKILL.md +296 -0
- data/.claude/skills/action-mailer-patterns/SKILL.md +295 -0
- data/.claude/skills/active-storage-setup/SKILL.md +311 -0
- data/.claude/skills/api-versioning/SKILL.md +294 -0
- data/.claude/skills/authentication-flow/SKILL.md +335 -0
- data/.claude/skills/authentication-flow/reference/current.md +248 -0
- data/.claude/skills/authentication-flow/reference/passwordless.md +253 -0
- data/.claude/skills/authentication-flow/reference/sessions.md +201 -0
- data/.claude/skills/authorization-pundit/SKILL.md +462 -0
- data/.claude/skills/caching-strategies/SKILL.md +350 -0
- data/.claude/skills/database-migrations/SKILL.md +354 -0
- data/.claude/skills/form-object-patterns/SKILL.md +399 -0
- data/.claude/skills/hotwire-patterns/SKILL.md +247 -0
- data/.claude/skills/hotwire-patterns/reference/stimulus.md +307 -0
- data/.claude/skills/hotwire-patterns/reference/tailwind-integration.md +112 -0
- data/.claude/skills/hotwire-patterns/reference/turbo-frames.md +158 -0
- data/.claude/skills/hotwire-patterns/reference/turbo-streams.md +218 -0
- data/.claude/skills/i18n-patterns/SKILL.md +320 -0
- data/.claude/skills/install/SKILL.md +367 -0
- data/.claude/skills/performance-optimization/SKILL.md +311 -0
- data/.claude/skills/rails-architecture/SKILL.md +259 -0
- data/.claude/skills/rails-architecture/reference/error-handling.md +333 -0
- data/.claude/skills/rails-architecture/reference/event-tracking.md +142 -0
- data/.claude/skills/rails-architecture/reference/layer-interactions.md +417 -0
- data/.claude/skills/rails-architecture/reference/multi-tenancy.md +152 -0
- data/.claude/skills/rails-architecture/reference/query-patterns.md +342 -0
- data/.claude/skills/rails-architecture/reference/service-patterns.md +286 -0
- data/.claude/skills/rails-architecture/reference/state-records.md +250 -0
- data/.claude/skills/rails-architecture/reference/testing-strategy.md +326 -0
- data/.claude/skills/rails-concern/SKILL.md +399 -0
- data/.claude/skills/rails-controller/SKILL.md +336 -0
- data/.claude/skills/rails-model-generator/SKILL.md +321 -0
- data/.claude/skills/rails-model-generator/reference/validations.md +298 -0
- data/.claude/skills/rails-presenter/SKILL.md +274 -0
- data/.claude/skills/rails-query-object/SKILL.md +289 -0
- data/.claude/skills/rails-service-object/SKILL.md +349 -0
- data/.claude/skills/solid-queue-setup/SKILL.md +307 -0
- data/.claude/skills/tdd-cycle/SKILL.md +359 -0
- data/.claude/skills/viewcomponent-patterns/SKILL.md +333 -0
- data/.gitignore +1 -0
- data/.rubocop.yml +2 -0
- data/.ruby-version +1 -1
- data/.vbw-planning/.notification-log.jsonl +192 -0
- data/.vbw-planning/.session-log.jsonl +871 -0
- data/.vbw-planning/PROJECT.md +51 -0
- data/.vbw-planning/REQUIREMENTS.md +50 -0
- data/.vbw-planning/SHIPPED.md +28 -0
- data/.vbw-planning/codebase/ARCHITECTURE.md +147 -0
- data/.vbw-planning/codebase/CONCERNS.md +99 -0
- data/.vbw-planning/codebase/CONVENTIONS.md +97 -0
- data/.vbw-planning/codebase/DEPENDENCIES.md +100 -0
- data/.vbw-planning/codebase/INDEX.md +86 -0
- data/.vbw-planning/codebase/META.md +42 -0
- data/.vbw-planning/codebase/PATTERNS.md +262 -0
- data/.vbw-planning/codebase/STACK.md +101 -0
- data/.vbw-planning/codebase/STRUCTURE.md +324 -0
- data/.vbw-planning/codebase/TESTING.md +154 -0
- data/.vbw-planning/config.json +12 -0
- data/.vbw-planning/discovery.json +24 -0
- data/.vbw-planning/milestones/default/ROADMAP.md +115 -0
- data/.vbw-planning/milestones/default/STATE.md +83 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01-SUMMARY.md +56 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01.md +187 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02-SUMMARY.md +64 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02.md +137 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01-SUMMARY.md +67 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01.md +142 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02-SUMMARY.md +64 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02.md +138 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03-SUMMARY.md +85 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03.md +147 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04-SUMMARY.md +63 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04.md +129 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05-SUMMARY.md +74 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05.md +154 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION-wave1.md +303 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION.md +510 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01-SUMMARY.md +61 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01.md +161 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02-SUMMARY.md +66 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02.md +132 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03-SUMMARY.md +59 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03.md +171 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04-SUMMARY.md +56 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04.md +152 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/04-CONTEXT.md +33 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01-SUMMARY.md +42 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01.md +119 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02-SUMMARY.md +52 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02.md +195 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03-SUMMARY.md +79 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03.md +130 -0
- data/CHANGELOG.md +28 -0
- data/CLAUDE.md +179 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +114 -101
- data/Rakefile +2 -0
- data/app/assets/builds/source_monitor/application.css +2076 -0
- data/app/assets/builds/source_monitor/application.js +2758 -0
- data/app/assets/builds/source_monitor/application.js.map +7 -0
- data/app/controllers/source_monitor/application_controller.rb +2 -0
- data/app/controllers/source_monitor/health_controller.rb +2 -0
- data/app/controllers/source_monitor/import_sessions/bulk_configuration.rb +106 -0
- data/app/controllers/source_monitor/import_sessions/entry_annotation.rb +187 -0
- data/app/controllers/source_monitor/import_sessions/health_check_management.rb +112 -0
- data/app/controllers/source_monitor/import_sessions/opml_parser.rb +130 -0
- data/app/controllers/source_monitor/import_sessions_controller.rb +6 -507
- data/app/controllers/source_monitor/items_controller.rb +2 -0
- data/app/controllers/source_monitor/sources_controller.rb +0 -14
- data/app/helpers/source_monitor/application_helper.rb +4 -112
- data/app/helpers/source_monitor/health_badge_helper.rb +69 -0
- data/app/helpers/source_monitor/table_sort_helper.rb +53 -0
- data/app/jobs/source_monitor/application_job.rb +2 -0
- data/app/models/source_monitor/application_record.rb +2 -0
- data/app/models/source_monitor/log_entry.rb +0 -2
- data/config/coverage_baseline.json +217 -1862
- data/config/routes.rb +2 -0
- data/db/migrate/20251009103000_add_feed_content_readability_to_sources.rb +2 -0
- data/db/migrate/20251014171659_add_performance_indexes.rb +2 -0
- data/db/migrate/20251014172525_add_fetch_status_check_constraint.rb +2 -0
- data/db/migrate/20251108120116_refresh_fetch_status_constraint.rb +2 -0
- data/db/migrate/20260210204022_add_composite_index_to_log_entries.rb +17 -0
- data/lib/source_monitor/assets/bundler.rb +2 -0
- data/lib/source_monitor/assets.rb +2 -0
- data/lib/source_monitor/configuration/authentication_settings.rb +62 -0
- data/lib/source_monitor/configuration/events.rb +60 -0
- data/lib/source_monitor/configuration/fetching_settings.rb +27 -0
- data/lib/source_monitor/configuration/health_settings.rb +27 -0
- data/lib/source_monitor/configuration/http_settings.rb +43 -0
- data/lib/source_monitor/configuration/model_definition.rb +108 -0
- data/lib/source_monitor/configuration/models.rb +36 -0
- data/lib/source_monitor/configuration/realtime_settings.rb +95 -0
- data/lib/source_monitor/configuration/retention_settings.rb +45 -0
- data/lib/source_monitor/configuration/scraper_registry.rb +67 -0
- data/lib/source_monitor/configuration/scraping_settings.rb +39 -0
- data/lib/source_monitor/configuration/validation_definition.rb +32 -0
- data/lib/source_monitor/configuration.rb +12 -579
- data/lib/source_monitor/dashboard/queries/recent_activity_query.rb +138 -0
- data/lib/source_monitor/dashboard/queries/stats_query.rb +71 -0
- data/lib/source_monitor/dashboard/queries.rb +2 -195
- data/lib/source_monitor/engine.rb +2 -0
- data/lib/source_monitor/fetching/feed_fetcher/adaptive_interval.rb +141 -0
- data/lib/source_monitor/fetching/feed_fetcher/entry_processor.rb +89 -0
- data/lib/source_monitor/fetching/feed_fetcher/source_updater.rb +200 -0
- data/lib/source_monitor/fetching/feed_fetcher.rb +37 -379
- data/lib/source_monitor/items/item_creator/content_extractor.rb +113 -0
- data/lib/source_monitor/items/item_creator/entry_parser/media_extraction.rb +96 -0
- data/lib/source_monitor/items/item_creator/entry_parser.rb +294 -0
- data/lib/source_monitor/items/item_creator.rb +28 -455
- data/lib/source_monitor/setup/bundle_installer.rb +2 -0
- data/lib/source_monitor/setup/cli.rb +2 -0
- data/lib/source_monitor/setup/dependency_checker.rb +2 -0
- data/lib/source_monitor/setup/detectors.rb +2 -0
- data/lib/source_monitor/setup/gemfile_editor.rb +2 -0
- data/lib/source_monitor/setup/initializer_patcher.rb +2 -0
- data/lib/source_monitor/setup/install_generator.rb +2 -0
- data/lib/source_monitor/setup/migration_installer.rb +2 -0
- data/lib/source_monitor/setup/node_installer.rb +2 -0
- data/lib/source_monitor/setup/prompter.rb +2 -0
- data/lib/source_monitor/setup/requirements.rb +2 -0
- data/lib/source_monitor/setup/shell_runner.rb +2 -0
- data/lib/source_monitor/setup/verification/action_cable_verifier.rb +2 -0
- data/lib/source_monitor/setup/verification/printer.rb +2 -0
- data/lib/source_monitor/setup/verification/result.rb +2 -0
- data/lib/source_monitor/setup/verification/runner.rb +2 -0
- data/lib/source_monitor/setup/verification/solid_queue_verifier.rb +2 -0
- data/lib/source_monitor/setup/verification/telemetry_logger.rb +2 -0
- data/lib/source_monitor/setup/workflow.rb +2 -0
- data/lib/source_monitor/version.rb +3 -1
- data/lib/source_monitor.rb +140 -58
- data/lib/tasks/source_monitor_assets.rake +2 -0
- data/lib/tasks/source_monitor_setup.rake +2 -0
- data/lib/tasks/source_monitor_tasks.rake +2 -0
- data/source_monitor.gemspec +3 -1
- 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
|
-
|
|
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
|