source_monitor 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/agents/rails-concern.md +464 -0
  3. data/.claude/agents/rails-controller.md +424 -0
  4. data/.claude/agents/rails-hotwire.md +446 -0
  5. data/.claude/agents/rails-implement.md +374 -0
  6. data/.claude/agents/rails-job.md +334 -0
  7. data/.claude/agents/rails-lint.md +294 -0
  8. data/.claude/agents/rails-mailer.md +371 -0
  9. data/.claude/agents/rails-migration.md +449 -0
  10. data/.claude/agents/rails-model.md +420 -0
  11. data/.claude/agents/rails-policy.md +443 -0
  12. data/.claude/agents/rails-presenter.md +427 -0
  13. data/.claude/agents/rails-query.md +412 -0
  14. data/.claude/agents/rails-review.md +490 -0
  15. data/.claude/agents/rails-service.md +458 -0
  16. data/.claude/agents/rails-state-records.md +465 -0
  17. data/.claude/agents/rails-tdd.md +314 -0
  18. data/.claude/agents/rails-test.md +441 -0
  19. data/.claude/agents/rails-view-component.md +418 -0
  20. data/.claude/hooks/block-secrets.sh +52 -0
  21. data/.claude/settings.json +85 -0
  22. data/.claude/skills/action-cable-patterns/SKILL.md +296 -0
  23. data/.claude/skills/action-mailer-patterns/SKILL.md +295 -0
  24. data/.claude/skills/active-storage-setup/SKILL.md +311 -0
  25. data/.claude/skills/api-versioning/SKILL.md +294 -0
  26. data/.claude/skills/authentication-flow/SKILL.md +335 -0
  27. data/.claude/skills/authentication-flow/reference/current.md +248 -0
  28. data/.claude/skills/authentication-flow/reference/passwordless.md +253 -0
  29. data/.claude/skills/authentication-flow/reference/sessions.md +201 -0
  30. data/.claude/skills/authorization-pundit/SKILL.md +462 -0
  31. data/.claude/skills/caching-strategies/SKILL.md +350 -0
  32. data/.claude/skills/database-migrations/SKILL.md +354 -0
  33. data/.claude/skills/form-object-patterns/SKILL.md +399 -0
  34. data/.claude/skills/hotwire-patterns/SKILL.md +247 -0
  35. data/.claude/skills/hotwire-patterns/reference/stimulus.md +307 -0
  36. data/.claude/skills/hotwire-patterns/reference/tailwind-integration.md +112 -0
  37. data/.claude/skills/hotwire-patterns/reference/turbo-frames.md +158 -0
  38. data/.claude/skills/hotwire-patterns/reference/turbo-streams.md +218 -0
  39. data/.claude/skills/i18n-patterns/SKILL.md +320 -0
  40. data/.claude/skills/install/SKILL.md +367 -0
  41. data/.claude/skills/performance-optimization/SKILL.md +311 -0
  42. data/.claude/skills/rails-architecture/SKILL.md +259 -0
  43. data/.claude/skills/rails-architecture/reference/error-handling.md +333 -0
  44. data/.claude/skills/rails-architecture/reference/event-tracking.md +142 -0
  45. data/.claude/skills/rails-architecture/reference/layer-interactions.md +417 -0
  46. data/.claude/skills/rails-architecture/reference/multi-tenancy.md +152 -0
  47. data/.claude/skills/rails-architecture/reference/query-patterns.md +342 -0
  48. data/.claude/skills/rails-architecture/reference/service-patterns.md +286 -0
  49. data/.claude/skills/rails-architecture/reference/state-records.md +250 -0
  50. data/.claude/skills/rails-architecture/reference/testing-strategy.md +326 -0
  51. data/.claude/skills/rails-concern/SKILL.md +399 -0
  52. data/.claude/skills/rails-controller/SKILL.md +336 -0
  53. data/.claude/skills/rails-model-generator/SKILL.md +321 -0
  54. data/.claude/skills/rails-model-generator/reference/validations.md +298 -0
  55. data/.claude/skills/rails-presenter/SKILL.md +274 -0
  56. data/.claude/skills/rails-query-object/SKILL.md +289 -0
  57. data/.claude/skills/rails-service-object/SKILL.md +349 -0
  58. data/.claude/skills/solid-queue-setup/SKILL.md +307 -0
  59. data/.claude/skills/tdd-cycle/SKILL.md +359 -0
  60. data/.claude/skills/viewcomponent-patterns/SKILL.md +333 -0
  61. data/.gitignore +1 -0
  62. data/.rubocop.yml +2 -0
  63. data/.ruby-version +1 -1
  64. data/.vbw-planning/.notification-log.jsonl +192 -0
  65. data/.vbw-planning/.session-log.jsonl +871 -0
  66. data/.vbw-planning/PROJECT.md +51 -0
  67. data/.vbw-planning/REQUIREMENTS.md +50 -0
  68. data/.vbw-planning/SHIPPED.md +28 -0
  69. data/.vbw-planning/codebase/ARCHITECTURE.md +147 -0
  70. data/.vbw-planning/codebase/CONCERNS.md +99 -0
  71. data/.vbw-planning/codebase/CONVENTIONS.md +97 -0
  72. data/.vbw-planning/codebase/DEPENDENCIES.md +100 -0
  73. data/.vbw-planning/codebase/INDEX.md +86 -0
  74. data/.vbw-planning/codebase/META.md +42 -0
  75. data/.vbw-planning/codebase/PATTERNS.md +262 -0
  76. data/.vbw-planning/codebase/STACK.md +101 -0
  77. data/.vbw-planning/codebase/STRUCTURE.md +324 -0
  78. data/.vbw-planning/codebase/TESTING.md +154 -0
  79. data/.vbw-planning/config.json +12 -0
  80. data/.vbw-planning/discovery.json +24 -0
  81. data/.vbw-planning/milestones/default/ROADMAP.md +115 -0
  82. data/.vbw-planning/milestones/default/STATE.md +83 -0
  83. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01-SUMMARY.md +56 -0
  84. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01.md +187 -0
  85. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02-SUMMARY.md +64 -0
  86. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02.md +137 -0
  87. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01-SUMMARY.md +67 -0
  88. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01.md +142 -0
  89. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02-SUMMARY.md +64 -0
  90. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02.md +138 -0
  91. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03-SUMMARY.md +85 -0
  92. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03.md +147 -0
  93. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04-SUMMARY.md +63 -0
  94. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04.md +129 -0
  95. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05-SUMMARY.md +74 -0
  96. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05.md +154 -0
  97. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION-wave1.md +303 -0
  98. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION.md +510 -0
  99. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01-SUMMARY.md +61 -0
  100. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01.md +161 -0
  101. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02-SUMMARY.md +66 -0
  102. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02.md +132 -0
  103. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03-SUMMARY.md +59 -0
  104. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03.md +171 -0
  105. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04-SUMMARY.md +56 -0
  106. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04.md +152 -0
  107. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/04-CONTEXT.md +33 -0
  108. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01-SUMMARY.md +42 -0
  109. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01.md +119 -0
  110. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02-SUMMARY.md +52 -0
  111. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02.md +195 -0
  112. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03-SUMMARY.md +79 -0
  113. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03.md +130 -0
  114. data/CHANGELOG.md +28 -0
  115. data/CLAUDE.md +179 -0
  116. data/Gemfile +8 -0
  117. data/Gemfile.lock +114 -101
  118. data/Rakefile +2 -0
  119. data/app/assets/builds/source_monitor/application.css +2076 -0
  120. data/app/assets/builds/source_monitor/application.js +2758 -0
  121. data/app/assets/builds/source_monitor/application.js.map +7 -0
  122. data/app/controllers/source_monitor/application_controller.rb +2 -0
  123. data/app/controllers/source_monitor/health_controller.rb +2 -0
  124. data/app/controllers/source_monitor/import_sessions/bulk_configuration.rb +106 -0
  125. data/app/controllers/source_monitor/import_sessions/entry_annotation.rb +187 -0
  126. data/app/controllers/source_monitor/import_sessions/health_check_management.rb +112 -0
  127. data/app/controllers/source_monitor/import_sessions/opml_parser.rb +130 -0
  128. data/app/controllers/source_monitor/import_sessions_controller.rb +6 -507
  129. data/app/controllers/source_monitor/items_controller.rb +2 -0
  130. data/app/controllers/source_monitor/sources_controller.rb +0 -14
  131. data/app/helpers/source_monitor/application_helper.rb +4 -112
  132. data/app/helpers/source_monitor/health_badge_helper.rb +69 -0
  133. data/app/helpers/source_monitor/table_sort_helper.rb +53 -0
  134. data/app/jobs/source_monitor/application_job.rb +2 -0
  135. data/app/models/source_monitor/application_record.rb +2 -0
  136. data/app/models/source_monitor/log_entry.rb +0 -2
  137. data/config/coverage_baseline.json +217 -1862
  138. data/config/routes.rb +2 -0
  139. data/db/migrate/20251009103000_add_feed_content_readability_to_sources.rb +2 -0
  140. data/db/migrate/20251014171659_add_performance_indexes.rb +2 -0
  141. data/db/migrate/20251014172525_add_fetch_status_check_constraint.rb +2 -0
  142. data/db/migrate/20251108120116_refresh_fetch_status_constraint.rb +2 -0
  143. data/db/migrate/20260210204022_add_composite_index_to_log_entries.rb +17 -0
  144. data/lib/source_monitor/assets/bundler.rb +2 -0
  145. data/lib/source_monitor/assets.rb +2 -0
  146. data/lib/source_monitor/configuration/authentication_settings.rb +62 -0
  147. data/lib/source_monitor/configuration/events.rb +60 -0
  148. data/lib/source_monitor/configuration/fetching_settings.rb +27 -0
  149. data/lib/source_monitor/configuration/health_settings.rb +27 -0
  150. data/lib/source_monitor/configuration/http_settings.rb +43 -0
  151. data/lib/source_monitor/configuration/model_definition.rb +108 -0
  152. data/lib/source_monitor/configuration/models.rb +36 -0
  153. data/lib/source_monitor/configuration/realtime_settings.rb +95 -0
  154. data/lib/source_monitor/configuration/retention_settings.rb +45 -0
  155. data/lib/source_monitor/configuration/scraper_registry.rb +67 -0
  156. data/lib/source_monitor/configuration/scraping_settings.rb +39 -0
  157. data/lib/source_monitor/configuration/validation_definition.rb +32 -0
  158. data/lib/source_monitor/configuration.rb +12 -579
  159. data/lib/source_monitor/dashboard/queries/recent_activity_query.rb +138 -0
  160. data/lib/source_monitor/dashboard/queries/stats_query.rb +71 -0
  161. data/lib/source_monitor/dashboard/queries.rb +2 -195
  162. data/lib/source_monitor/engine.rb +2 -0
  163. data/lib/source_monitor/fetching/feed_fetcher/adaptive_interval.rb +141 -0
  164. data/lib/source_monitor/fetching/feed_fetcher/entry_processor.rb +89 -0
  165. data/lib/source_monitor/fetching/feed_fetcher/source_updater.rb +200 -0
  166. data/lib/source_monitor/fetching/feed_fetcher.rb +37 -379
  167. data/lib/source_monitor/items/item_creator/content_extractor.rb +113 -0
  168. data/lib/source_monitor/items/item_creator/entry_parser/media_extraction.rb +96 -0
  169. data/lib/source_monitor/items/item_creator/entry_parser.rb +294 -0
  170. data/lib/source_monitor/items/item_creator.rb +28 -455
  171. data/lib/source_monitor/setup/bundle_installer.rb +2 -0
  172. data/lib/source_monitor/setup/cli.rb +2 -0
  173. data/lib/source_monitor/setup/dependency_checker.rb +2 -0
  174. data/lib/source_monitor/setup/detectors.rb +2 -0
  175. data/lib/source_monitor/setup/gemfile_editor.rb +2 -0
  176. data/lib/source_monitor/setup/initializer_patcher.rb +2 -0
  177. data/lib/source_monitor/setup/install_generator.rb +2 -0
  178. data/lib/source_monitor/setup/migration_installer.rb +2 -0
  179. data/lib/source_monitor/setup/node_installer.rb +2 -0
  180. data/lib/source_monitor/setup/prompter.rb +2 -0
  181. data/lib/source_monitor/setup/requirements.rb +2 -0
  182. data/lib/source_monitor/setup/shell_runner.rb +2 -0
  183. data/lib/source_monitor/setup/verification/action_cable_verifier.rb +2 -0
  184. data/lib/source_monitor/setup/verification/printer.rb +2 -0
  185. data/lib/source_monitor/setup/verification/result.rb +2 -0
  186. data/lib/source_monitor/setup/verification/runner.rb +2 -0
  187. data/lib/source_monitor/setup/verification/solid_queue_verifier.rb +2 -0
  188. data/lib/source_monitor/setup/verification/telemetry_logger.rb +2 -0
  189. data/lib/source_monitor/setup/workflow.rb +2 -0
  190. data/lib/source_monitor/version.rb +3 -1
  191. data/lib/source_monitor.rb +140 -58
  192. data/lib/tasks/source_monitor_assets.rake +2 -0
  193. data/lib/tasks/source_monitor_setup.rake +2 -0
  194. data/lib/tasks/source_monitor_tasks.rake +2 -0
  195. data/source_monitor.gemspec +3 -1
  196. metadata +144 -4
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module ImportSessions
5
+ module EntryAnnotation
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def annotated_entries(selected_ids)
11
+ selected_ids ||= []
12
+ entries = Array(@import_session.parsed_sources)
13
+ return [] if entries.blank?
14
+
15
+ normalized = entries.map { |entry| normalize_entry(entry) }
16
+
17
+ feed_urls = normalized.filter_map { |entry| entry[:feed_url]&.downcase }
18
+ duplicate_lookup = if feed_urls.present?
19
+ SourceMonitor::Source.where("LOWER(feed_url) IN (?)", feed_urls).pluck(:feed_url).map(&:downcase)
20
+ else
21
+ []
22
+ end
23
+
24
+ normalized.map do |entry|
25
+ duplicate = entry[:feed_url].present? && duplicate_lookup.include?(entry[:feed_url].downcase)
26
+ entry.merge(
27
+ duplicate: duplicate,
28
+ selectable: entry[:status] == "valid" && !duplicate,
29
+ selected: selected_ids.include?(entry[:id])
30
+ )
31
+ end
32
+ end
33
+
34
+ def normalize_entry(entry)
35
+ entry = entry.to_h
36
+ SourceMonitor::ImportSessions::EntryNormalizer.normalize(entry)
37
+ end
38
+
39
+ def filter_entries(entries, filter)
40
+ case filter
41
+ when "new"
42
+ entries.select { |entry| entry[:selectable] }
43
+ when "existing"
44
+ entries.select { |entry| entry[:duplicate] }
45
+ else
46
+ entries
47
+ end
48
+ end
49
+
50
+ def selectable_entries_from(entries)
51
+ entries.select { |entry| entry[:selectable] }
52
+ end
53
+
54
+ def selectable_entries
55
+ @selectable_entries ||= annotated_entries(@selected_source_ids).select { |entry| entry[:selectable] }
56
+ end
57
+
58
+ def build_selection_from_params
59
+ @selected_source_ids ||= []
60
+
61
+ if params.dig(:import_session, :select_all) == "true"
62
+ return selectable_entries.map { |entry| entry[:id] }
63
+ end
64
+
65
+ if params.dig(:import_session, :select_none) == "true"
66
+ return []
67
+ end
68
+
69
+ ids = params.dig(:import_session, :selected_source_ids)
70
+ return [] unless ids
71
+
72
+ Array(ids).map { |id| id.to_s }.uniq
73
+ end
74
+
75
+ def health_check_selection_from_params
76
+ if params.dig(:import_session, :select_all) == "true"
77
+ return health_check_targets.dup
78
+ end
79
+
80
+ return [] if params.dig(:import_session, :select_none) == "true"
81
+
82
+ ids = params.dig(:import_session, :selected_source_ids)
83
+ return Array(@import_session.selected_source_ids).map(&:to_s) unless ids
84
+
85
+ Array(ids).map { |id| id.to_s }.uniq & health_check_targets
86
+ end
87
+
88
+ def advancing_from_health_check?
89
+ target_step != "health_check"
90
+ end
91
+
92
+ def advancing_from_preview?
93
+ target_step != "preview"
94
+ end
95
+
96
+ def normalize_page_param(value)
97
+ number = value.to_i
98
+ number = 1 if number <= 0
99
+ number
100
+ rescue StandardError
101
+ 1
102
+ end
103
+
104
+ def permitted_filter(raw)
105
+ value = raw.to_s.presence
106
+ return unless value
107
+
108
+ %w[all new existing].find { |candidate| candidate == value }
109
+ end
110
+
111
+ def preview_per_page
112
+ 25
113
+ end
114
+
115
+ def state_params
116
+ @state_params ||= begin
117
+ permitted = params.fetch(:import_session, {}).permit(
118
+ :current_step,
119
+ :next_step,
120
+ :select_all,
121
+ :select_none,
122
+ parsed_sources: [],
123
+ selected_source_ids: [],
124
+ bulk_settings: {},
125
+ opml_file_metadata: {}
126
+ )
127
+
128
+ SourceMonitor::Security::ParameterSanitizer.sanitize(permitted.to_h)
129
+ end
130
+ end
131
+
132
+ def permitted_step(value)
133
+ step = value.to_s.presence
134
+ return unless step
135
+
136
+ ImportSession::STEP_ORDER.find { |candidate| candidate == step }
137
+ end
138
+
139
+ def target_step
140
+ next_step = state_params[:next_step] || state_params["next_step"]
141
+ permitted_step(next_step) || @current_step || ImportSession.default_step
142
+ end
143
+
144
+ def session_attributes
145
+ attrs = state_params.except(:next_step, :current_step, "next_step", "current_step")
146
+ attrs[:opml_file_metadata] = build_file_metadata if uploading_file?
147
+ attrs[:current_step] = target_step
148
+ attrs
149
+ end
150
+
151
+ def prepare_preview_context(skip_default: false)
152
+ @filter = permitted_filter(params[:filter]) || "all"
153
+ @page = normalize_page_param(params[:page])
154
+ @selected_source_ids = Array(@import_session.selected_source_ids).map(&:to_s)
155
+
156
+ @preview_entries = annotated_entries(@selected_source_ids)
157
+
158
+ if !skip_default && @selected_source_ids.blank? && @preview_entries.present?
159
+ defaults = selectable_entries_from(@preview_entries).map { |entry| entry[:id] }
160
+ @selected_source_ids = defaults
161
+ @import_session.update_column(:selected_source_ids, defaults)
162
+ @preview_entries = annotated_entries(@selected_source_ids)
163
+ end
164
+
165
+ @filtered_entries = filter_entries(@preview_entries, @filter)
166
+
167
+ paginator = SourceMonitor::Pagination::Paginator.new(
168
+ scope: @filtered_entries,
169
+ page: @page,
170
+ per_page: preview_per_page
171
+ ).paginate
172
+
173
+ @paginated_entries = paginator.records
174
+ @has_next_page = paginator.has_next_page
175
+ @has_previous_page = paginator.has_previous_page
176
+ @page = paginator.page
177
+ end
178
+
179
+ def prepare_confirm_context
180
+ @selected_source_ids = Array(@import_session.selected_source_ids).map(&:to_s)
181
+ @selected_entries = annotated_entries(@selected_source_ids)
182
+ .select { |entry| @selected_source_ids.include?(entry[:id]) }
183
+ @bulk_settings = @import_session.bulk_settings || {}
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module ImportSessions
5
+ module HealthCheckManagement
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def start_health_checks_if_needed
11
+ return unless @current_step == "health_check"
12
+
13
+ jobs_to_enqueue = []
14
+
15
+ @import_session.with_lock do
16
+ @import_session.reload
17
+ selected = Array(@import_session.selected_source_ids).map(&:to_s)
18
+
19
+ if selected.blank?
20
+ @import_session.update_columns(health_checks_active: false, health_check_target_ids: [])
21
+ next
22
+ end
23
+
24
+ if @import_session.health_checks_active? && @import_session.health_check_targets.sort == selected.sort
25
+ @health_check_target_ids = @import_session.health_check_targets
26
+ next
27
+ end
28
+
29
+ updated_entries = reset_health_results(@import_session.parsed_sources, selected)
30
+ @import_session.update!(
31
+ parsed_sources: updated_entries,
32
+ health_checks_active: true,
33
+ health_check_target_ids: selected,
34
+ health_check_started_at: Time.current,
35
+ health_check_completed_at: nil
36
+ )
37
+
38
+ @health_check_target_ids = selected
39
+ jobs_to_enqueue = selected
40
+ end
41
+
42
+ enqueue_health_check_jobs(@import_session, jobs_to_enqueue) if jobs_to_enqueue.any?
43
+ end
44
+
45
+ def reset_health_results(entries, target_ids)
46
+ Array(entries).map do |entry|
47
+ entry_hash = entry.to_h
48
+ entry_id = entry_hash["id"] || entry_hash[:id]
49
+ next entry_hash unless target_ids.include?(entry_id.to_s)
50
+
51
+ entry_hash.merge("health_status" => "pending", "health_error" => nil)
52
+ end
53
+ end
54
+
55
+ def enqueue_health_check_jobs(import_session, target_ids)
56
+ target_ids.each do |target_id|
57
+ SourceMonitor::ImportSessionHealthCheckJob.set(wait: 1.second).perform_later(import_session.id, target_id)
58
+ end
59
+ end
60
+
61
+ def deactivate_health_checks!
62
+ return unless @import_session.health_checks_active?
63
+
64
+ @import_session.update_columns(
65
+ health_checks_active: false,
66
+ health_check_completed_at: Time.current
67
+ )
68
+ end
69
+
70
+ def health_check_entries(selected_ids)
71
+ targets = health_check_targets
72
+ entries = Array(@import_session.parsed_sources).map { |entry| normalize_entry(entry) }
73
+
74
+ entries.select { |entry| targets.include?(entry[:id]) }.map do |entry|
75
+ entry.merge(selected: selected_ids.include?(entry[:id]))
76
+ end
77
+ end
78
+
79
+ def health_check_progress(entries)
80
+ total = health_check_targets.size
81
+ completed = entries.count { |entry| health_check_complete?(entry) }
82
+
83
+ {
84
+ completed: completed,
85
+ total: total,
86
+ pending: [ total - completed, 0 ].max,
87
+ active: @import_session.health_checks_active?,
88
+ done: total.positive? && completed >= total
89
+ }
90
+ end
91
+
92
+ def health_check_complete?(entry)
93
+ %w[healthy unhealthy].include?(entry[:health_status].to_s)
94
+ end
95
+
96
+ def health_check_targets
97
+ targets = @import_session.health_check_targets
98
+ targets = Array(@import_session.selected_source_ids).map(&:to_s) if targets.blank?
99
+ targets
100
+ end
101
+
102
+ def prepare_health_check_context
103
+ start_health_checks_if_needed
104
+
105
+ @selected_source_ids = Array(@import_session.selected_source_ids).map(&:to_s)
106
+ @health_check_entries = health_check_entries(@selected_source_ids)
107
+ @health_check_target_ids = health_check_targets
108
+ @health_progress = health_check_progress(@health_check_entries)
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module ImportSessions
5
+ module OpmlParser
6
+ extend ActiveSupport::Concern
7
+
8
+ ALLOWED_CONTENT_TYPES = %w[text/xml application/xml text/x-opml application/opml].freeze
9
+ GENERIC_CONTENT_TYPES = %w[application/octet-stream binary/octet-stream].freeze
10
+
11
+ class UploadError < StandardError; end
12
+
13
+ private
14
+
15
+ def build_file_metadata
16
+ return {} unless params[:opml_file].respond_to?(:original_filename)
17
+
18
+ file = params[:opml_file]
19
+ {
20
+ "filename" => file.original_filename,
21
+ "byte_size" => file.size,
22
+ "content_type" => file.content_type
23
+ }
24
+ end
25
+
26
+ def uploading_file?
27
+ params[:opml_file].present?
28
+ end
29
+
30
+ def validate_upload!
31
+ return [ "Upload an OPML file to continue." ] unless uploading_file?
32
+
33
+ file = params[:opml_file]
34
+ errors = []
35
+
36
+ errors << "The uploaded file is empty. Choose another OPML file." if file.size.to_i <= 0
37
+
38
+ if file.content_type.present? && !content_type_allowed?(file.content_type) && !generic_content_type?(file.content_type)
39
+ errors << "Upload must be an OPML or XML file."
40
+ end
41
+
42
+ errors
43
+ end
44
+
45
+ def content_type_allowed?(content_type)
46
+ ALLOWED_CONTENT_TYPES.include?(content_type)
47
+ end
48
+
49
+ def generic_content_type?(content_type)
50
+ GENERIC_CONTENT_TYPES.include?(content_type)
51
+ end
52
+
53
+ def parse_opml_file(file)
54
+ content = file.read
55
+ file.rewind if file.respond_to?(:rewind)
56
+
57
+ raise UploadError, "The uploaded file appears to be empty." if content.blank?
58
+
59
+ document = Nokogiri::XML(content) { |config| config.strict.nonet }
60
+ raise UploadError, "The uploaded file is not valid XML or OPML." if document.root.nil?
61
+
62
+ outlines = document.xpath("//outline")
63
+
64
+ entries = []
65
+
66
+ outlines.each_with_index do |outline, index|
67
+ next unless outline.attribute_nodes.any? { |attr| attr.name.casecmp("xmlurl").zero? }
68
+
69
+ entries << build_entry(outline, index)
70
+ end
71
+
72
+ entries
73
+ rescue Nokogiri::XML::SyntaxError => error
74
+ raise UploadError, "We couldn't parse that OPML file: #{error.message}"
75
+ end
76
+
77
+ def build_entry(outline, index)
78
+ feed_url = outline_attribute(outline, "xmlUrl")
79
+ website_url = outline_attribute(outline, "htmlUrl")
80
+ title = outline_attribute(outline, "title") || outline_attribute(outline, "text")
81
+
82
+ if feed_url.blank?
83
+ return malformed_entry(index, feed_url, title, website_url, "Missing feed URL")
84
+ end
85
+
86
+ unless valid_feed_url?(feed_url)
87
+ return malformed_entry(index, feed_url, title, website_url, "Feed URL must be HTTP or HTTPS")
88
+ end
89
+
90
+ {
91
+ id: "outline-#{index}",
92
+ raw_outline_index: index,
93
+ feed_url: feed_url,
94
+ title: title,
95
+ website_url: website_url,
96
+ status: "valid",
97
+ error: nil,
98
+ health_status: nil,
99
+ health_error: nil
100
+ }
101
+ end
102
+
103
+ def malformed_entry(index, feed_url, title, website_url, error)
104
+ {
105
+ id: "outline-#{index}",
106
+ raw_outline_index: index,
107
+ feed_url: feed_url.presence,
108
+ title: title,
109
+ website_url: website_url,
110
+ status: "malformed",
111
+ error: error,
112
+ health_status: nil,
113
+ health_error: nil
114
+ }
115
+ end
116
+
117
+ def outline_attribute(outline, name)
118
+ attribute = outline.attribute_nodes.find { |attr| attr.name.casecmp(name).zero? }
119
+ attribute&.value.to_s.presence
120
+ end
121
+
122
+ def valid_feed_url?(url)
123
+ parsed = URI.parse(url)
124
+ parsed.is_a?(URI::HTTP) && parsed.host.present?
125
+ rescue URI::InvalidURIError
126
+ false
127
+ end
128
+ end
129
+ end
130
+ end