source_monitor 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/Gemfile.lock +1 -1
  4. data/app/assets/javascripts/source_monitor/application.js +4 -0
  5. data/app/assets/javascripts/source_monitor/controllers/confirm_navigation_controller.js +49 -0
  6. data/app/assets/javascripts/source_monitor/controllers/select_all_controller.js +36 -0
  7. data/app/controllers/source_monitor/import_sessions_controller.rb +791 -0
  8. data/app/controllers/source_monitor/sources_controller.rb +5 -36
  9. data/app/helpers/source_monitor/application_helper.rb +17 -0
  10. data/app/jobs/source_monitor/import_opml_job.rb +150 -0
  11. data/app/jobs/source_monitor/import_session_health_check_job.rb +93 -0
  12. data/app/models/source_monitor/import_history.rb +35 -0
  13. data/app/models/source_monitor/import_session.rb +34 -0
  14. data/app/views/source_monitor/import_sessions/_header.html.erb +12 -0
  15. data/app/views/source_monitor/import_sessions/_sidebar.html.erb +23 -0
  16. data/app/views/source_monitor/import_sessions/health_check/_progress.html.erb +20 -0
  17. data/app/views/source_monitor/import_sessions/health_check/_row.html.erb +44 -0
  18. data/app/views/source_monitor/import_sessions/show.html.erb +15 -0
  19. data/app/views/source_monitor/import_sessions/show.turbo_stream.erb +1 -0
  20. data/app/views/source_monitor/import_sessions/steps/_configure.html.erb +53 -0
  21. data/app/views/source_monitor/import_sessions/steps/_confirm.html.erb +121 -0
  22. data/app/views/source_monitor/import_sessions/steps/_health_check.html.erb +82 -0
  23. data/app/views/source_monitor/import_sessions/steps/_navigation.html.erb +29 -0
  24. data/app/views/source_monitor/import_sessions/steps/_preview.html.erb +172 -0
  25. data/app/views/source_monitor/import_sessions/steps/_upload.html.erb +42 -0
  26. data/app/views/source_monitor/sources/_form.html.erb +8 -138
  27. data/app/views/source_monitor/sources/_form_fields.html.erb +142 -0
  28. data/app/views/source_monitor/sources/_import_history_panel.html.erb +53 -0
  29. data/app/views/source_monitor/sources/index.html.erb +7 -1
  30. data/config/coverage_baseline.json +91 -15
  31. data/config/routes.rb +6 -0
  32. data/db/migrate/20251124090000_create_import_sessions.rb +18 -0
  33. data/db/migrate/20251124153000_add_health_fields_to_import_sessions.rb +14 -0
  34. data/db/migrate/20251125094500_create_import_histories.rb +19 -0
  35. data/lib/source_monitor/health/import_source_health_check.rb +55 -0
  36. data/lib/source_monitor/health.rb +1 -0
  37. data/lib/source_monitor/import_sessions/entry_normalizer.rb +30 -0
  38. data/lib/source_monitor/import_sessions/health_check_broadcaster.rb +103 -0
  39. data/lib/source_monitor/sources/params.rb +52 -0
  40. data/lib/source_monitor/version.rb +1 -1
  41. data/tasks/completed/codebase_audit_2025.md +1396 -0
  42. data/tasks/completed/engine-asset-configuration.md +203 -0
  43. data/tasks/completed/opml-import-wizard/opml-import-wizard-product-brief.md +58 -0
  44. data/tasks/completed/opml-import-wizard/opml-import-wizard-tech-brief.md +75 -0
  45. data/tasks/completed/opml-import-wizard/task-01/instructions.md +81 -0
  46. data/tasks/completed/opml-import-wizard/task-01/requirements.md +19 -0
  47. data/tasks/completed/opml-import-wizard/task-02/instructions.md +83 -0
  48. data/tasks/completed/opml-import-wizard/task-02/requirements.md +18 -0
  49. data/tasks/completed/opml-import-wizard/task-03/instructions.md +58 -0
  50. data/tasks/completed/opml-import-wizard/task-03/requirements.md +18 -0
  51. data/tasks/completed/opml-import-wizard/task-04/instructions.md +84 -0
  52. data/tasks/completed/opml-import-wizard/task-04/requirements.md +17 -0
  53. data/tasks/completed/opml-import-wizard/task-05/instructions.md +50 -0
  54. data/tasks/completed/opml-import-wizard/task-05/requirements.md +17 -0
  55. data/tasks/completed/opml-import-wizard/task-06/instructions.md +92 -0
  56. data/tasks/completed/opml-import-wizard/task-06/requirements.md +21 -0
  57. data/tasks/completed/phase_17_01_complexity_audit_2025-10-12.md +62 -0
  58. data/tasks/completed/phase_17_02_complexity_findings_2025-10-12.md +74 -0
  59. data/tasks/completed/phase_17_03_refactor_plan_2025-10-12.md +37 -0
  60. data/tasks/completed/phase_21_01_log_consolidation_2025-10-15.md +30 -0
  61. data/tasks/completed/release_checklist.md +23 -0
  62. data/tasks/completed/routes_refactor_evaluation.md +109 -0
  63. data/tasks/completed/source_monitor_rename_plan.md +70 -0
  64. data/tasks/completed/tasks.md +952 -0
  65. data/tasks/ideas.md +10 -0
  66. metadata +56 -3
  67. /data/tasks/{prd-setup-workflow-streamlining.md → completed/prd-setup-workflow-streamlining.md} +0 -0
  68. /data/tasks/{tasks-setup-workflow-streamlining.md → completed/tasks-setup-workflow-streamlining.md} +0 -0
@@ -0,0 +1,121 @@
1
+ <div id="import_session_confirm" class="space-y-6">
2
+ <div class="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
3
+ <div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
4
+ <div>
5
+ <h2 class="text-lg font-semibold text-slate-900">Review &amp; confirm</h2>
6
+ <p class="mt-1 text-sm text-slate-600">
7
+ Apply these settings to the <%= @selected_entries.size %> feeds you selected. Duplicates will be skipped automatically.
8
+ </p>
9
+ </div>
10
+ <div class="flex items-center gap-3 text-sm">
11
+ <span class="inline-flex items-center rounded-full bg-green-50 px-3 py-1 font-semibold text-green-700">
12
+ Selected: <%= @selected_entries.size %>
13
+ </span>
14
+ <span class="inline-flex items-center rounded-full bg-amber-50 px-3 py-1 font-semibold text-amber-800">
15
+ Skipped duplicates: <%= @selected_entries.count { |entry| entry[:duplicate] } %>
16
+ </span>
17
+ </div>
18
+ </div>
19
+ <% if @selection_error.present? %>
20
+ <div class="mt-3 rounded-md bg-red-50 px-3 py-2 text-sm text-red-800">
21
+ <%= @selection_error %>
22
+ </div>
23
+ <% end %>
24
+ </div>
25
+
26
+ <div class="rounded-lg border border-slate-200 bg-white shadow-sm">
27
+ <div class="flex items-center justify-between border-b border-slate-100 px-4 py-3">
28
+ <div>
29
+ <h3 class="text-base font-semibold text-slate-900">Sources to import</h3>
30
+ <p class="text-sm text-slate-600">Each feed will be created individually; duplicates are skipped.</p>
31
+ </div>
32
+ <span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-slate-600">
33
+ <%= @selected_entries.size %> total
34
+ </span>
35
+ </div>
36
+ <div class="overflow-x-auto">
37
+ <table class="min-w-full divide-y divide-slate-200 text-left text-sm">
38
+ <thead class="bg-slate-50 text-xs font-semibold uppercase tracking-wide text-slate-500">
39
+ <tr>
40
+ <th scope="col" class="px-4 py-3">Title</th>
41
+ <th scope="col" class="px-4 py-3">Feed URL</th>
42
+ <th scope="col" class="px-4 py-3">Website</th>
43
+ <th scope="col" class="px-4 py-3">Status</th>
44
+ </tr>
45
+ </thead>
46
+ <tbody class="divide-y divide-slate-100 text-slate-700">
47
+ <% @selected_entries.each do |entry| %>
48
+ <tr>
49
+ <td class="px-4 py-3 align-top font-semibold text-slate-900">
50
+ <%= entry[:title].presence || entry[:feed_url] %>
51
+ </td>
52
+ <td class="px-4 py-3 align-top text-slate-700">
53
+ <div class="break-all font-mono text-xs"><%= entry[:feed_url] %></div>
54
+ </td>
55
+ <td class="px-4 py-3 align-top text-slate-700">
56
+ <% if entry[:website_url].present? %>
57
+ <div class="break-all text-xs text-blue-700"><%= entry[:website_url] %></div>
58
+ <% else %>
59
+ <span class="text-xs text-slate-400">—</span>
60
+ <% end %>
61
+ </td>
62
+ <td class="px-4 py-3 align-top">
63
+ <% if entry[:duplicate] %>
64
+ <span class="inline-flex items-center rounded-full bg-amber-50 px-2 py-1 text-xs font-semibold uppercase tracking-wide text-amber-800">Duplicate</span>
65
+ <% else %>
66
+ <span class="inline-flex items-center rounded-full bg-green-50 px-2 py-1 text-xs font-semibold uppercase tracking-wide text-green-700">Ready</span>
67
+ <% end %>
68
+ </td>
69
+ </tr>
70
+ <% end %>
71
+ </tbody>
72
+ </table>
73
+ </div>
74
+ </div>
75
+
76
+ <div class="rounded-lg border border-slate-200 bg-white shadow-sm">
77
+ <div class="flex items-center justify-between border-b border-slate-100 px-4 py-3">
78
+ <div>
79
+ <h3 class="text-base font-semibold text-slate-900">Bulk settings</h3>
80
+ <p class="text-sm text-slate-600">These options apply to every new source created by this import.</p>
81
+ </div>
82
+ </div>
83
+ <div class="px-4 py-4">
84
+ <% if @bulk_settings.present? %>
85
+ <dl class="grid gap-3 md:grid-cols-2">
86
+ <% @bulk_settings.each do |key, value| %>
87
+ <div class="rounded-md border border-slate-100 bg-slate-50 px-3 py-2">
88
+ <dt class="text-xs font-semibold uppercase tracking-wide text-slate-500"><%= key.to_s.humanize %></dt>
89
+ <dd class="mt-1 text-sm text-slate-800"><%= formatted_setting_value(value) %></dd>
90
+ </div>
91
+ <% end %>
92
+ </dl>
93
+ <% else %>
94
+ <p class="text-sm text-slate-600">No additional settings selected. Defaults will be used.</p>
95
+ <% end %>
96
+ </div>
97
+ </div>
98
+
99
+ <div class="flex items-center justify-between gap-3">
100
+ <%= form_with model: import_session,
101
+ url: source_monitor.step_import_session_path(import_session, step: "configure"),
102
+ method: :patch,
103
+ data: { turbo: false } do |form| %>
104
+ <%= hidden_field_tag :next_step, "configure", name: "import_session[next_step]" %>
105
+ <%= form.submit "Back", class: "inline-flex items-center rounded-md border border-slate-200 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50" %>
106
+ <% end %>
107
+
108
+ <div class="flex items-center gap-3">
109
+ <%= link_to "Cancel", source_monitor.import_session_path(import_session),
110
+ data: { turbo_method: :delete, action: "confirm-navigation#disable" },
111
+ class: "inline-flex items-center rounded-md border border-slate-200 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50" %>
112
+
113
+ <%= form_with model: import_session,
114
+ url: source_monitor.step_import_session_path(import_session, step: "confirm"),
115
+ method: :patch do |form| %>
116
+ <%= hidden_field_tag :next_step, "confirm", name: "import_session[next_step]" %>
117
+ <%= form.submit "Start import", class: "inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500" %>
118
+ <% end %>
119
+ </div>
120
+ </div>
121
+ </div>
@@ -0,0 +1,82 @@
1
+ <%= turbo_stream_from import_session.health_stream_name %>
2
+
3
+ <div class="space-y-6">
4
+ <div class="rounded-lg border border-slate-200 bg-white shadow-sm">
5
+ <div class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-100 px-4 py-3">
6
+ <div>
7
+ <h2 class="text-lg font-semibold text-slate-900">Run health checks</h2>
8
+ <p class="mt-1 text-sm text-slate-600">We check each selected feed and update results live. Unhealthy feeds are unselected by default.</p>
9
+ </div>
10
+ <%= render "source_monitor/import_sessions/health_check/progress", import_session: import_session, progress: @health_progress %>
11
+ </div>
12
+
13
+ <% if @selection_error.present? %>
14
+ <div class="border-b border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
15
+ <%= @selection_error %>
16
+ </div>
17
+ <% end %>
18
+
19
+ <% if @health_check_entries.blank? %>
20
+ <div class="px-4 py-6 text-sm text-slate-600">
21
+ No sources are selected for health checks. Go back to Preview to pick at least one feed.
22
+ </div>
23
+ <% else %>
24
+ <%= form_with model: import_session,
25
+ url: source_monitor.step_import_session_path(import_session, step: "health_check"),
26
+ method: :patch,
27
+ data: { turbo: false, action: "confirm-navigation#disable" },
28
+ html: { class: "block" } do |form| %>
29
+ <div class="overflow-x-auto">
30
+ <table class="min-w-full divide-y divide-slate-200 text-left text-sm" data-controller="select-all">
31
+ <thead class="bg-slate-50 text-xs font-semibold uppercase tracking-wide text-slate-500">
32
+ <tr>
33
+ <th scope="col" class="px-4 py-3 w-12 text-center">
34
+ <div class="flex items-center justify-center gap-2">
35
+ <span class="sr-only">Select all</span>
36
+ <%= check_box_tag "select_all_master", "1", @health_check_entries.all? { |entry| entry[:selected] },
37
+ data: { select_all_target: "master", action: "select-all#toggleAll" },
38
+ class: "h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500" %>
39
+ </div>
40
+ </th>
41
+ <th scope="col" class="px-4 py-3">Feed URL</th>
42
+ <th scope="col" class="px-4 py-3">Title</th>
43
+ <th scope="col" class="px-4 py-3">Health</th>
44
+ </tr>
45
+ </thead>
46
+ <tbody class="divide-y divide-slate-100 text-slate-800">
47
+ <%= render partial: "source_monitor/import_sessions/health_check/row",
48
+ collection: @health_check_entries,
49
+ as: :entry,
50
+ locals: { import_session: import_session } %>
51
+ </tbody>
52
+ </table>
53
+ </div>
54
+
55
+ <div class="flex items-center justify-between gap-3 border-t border-slate-200 px-4 py-4">
56
+ <div>
57
+ <%= button_tag "Back",
58
+ type: :submit,
59
+ name: "import_session[next_step]",
60
+ value: "preview",
61
+ class: "inline-flex items-center rounded-md border border-slate-200 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50" %>
62
+ </div>
63
+ <div class="flex items-center gap-3">
64
+ <%= link_to "Cancel", source_monitor.import_session_path(import_session),
65
+ data: { turbo_method: :delete, action: "confirm-navigation#disable" },
66
+ class: "inline-flex items-center rounded-md border border-slate-200 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50" %>
67
+ <% continue_disabled = @selected_source_ids.blank? %>
68
+ <%= button_tag "Continue",
69
+ type: :submit,
70
+ name: "import_session[next_step]",
71
+ value: "configure",
72
+ disabled: continue_disabled,
73
+ class: [
74
+ "inline-flex items-center rounded-md px-4 py-2 text-sm font-semibold text-white shadow",
75
+ continue_disabled ? "bg-slate-300 cursor-not-allowed" : "bg-blue-600 hover:bg-blue-500"
76
+ ].join(" ") %>
77
+ </div>
78
+ </div>
79
+ <% end %>
80
+ <% end %>
81
+ </div>
82
+ </div>
@@ -0,0 +1,29 @@
1
+ <% previous_step ||= nil %>
2
+ <% next_step ||= nil %>
3
+ <div class="flex items-center justify-between gap-3">
4
+ <div>
5
+ <% if previous_step.present? %>
6
+ <%= form_with model: import_session,
7
+ url: source_monitor.step_import_session_path(import_session, step: previous_step),
8
+ method: :patch,
9
+ data: { turbo: false } do |form| %>
10
+ <%= hidden_field_tag :next_step, previous_step, name: "import_session[next_step]" %>
11
+ <%= form.submit "Back", class: "inline-flex items-center rounded-md border border-slate-200 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50" %>
12
+ <% end %>
13
+ <% end %>
14
+ </div>
15
+ <div class="flex items-center gap-3">
16
+ <%= link_to "Cancel", source_monitor.import_session_path(import_session),
17
+ data: { turbo_method: :delete, action: "confirm-navigation#disable" },
18
+ class: "inline-flex items-center rounded-md border border-slate-200 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50" %>
19
+ <% if next_step.present? %>
20
+ <%= form_with model: import_session,
21
+ url: source_monitor.step_import_session_path(import_session, step: next_step),
22
+ method: :patch,
23
+ data: { turbo: false } do |form| %>
24
+ <%= hidden_field_tag :next_step, next_step, name: "import_session[next_step]" %>
25
+ <%= form.submit "Continue", class: "inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500" %>
26
+ <% end %>
27
+ <% end %>
28
+ </div>
29
+ </div>
@@ -0,0 +1,172 @@
1
+ <div class="space-y-6">
2
+ <div class="rounded-lg border border-slate-200 bg-white shadow-sm">
3
+ <div class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-100 px-4 py-3">
4
+ <div>
5
+ <h2 class="text-lg font-semibold text-slate-900">Preview sources</h2>
6
+ <p class="mt-1 text-sm text-slate-600">Select the feeds to import. Duplicates and malformed entries are disabled.</p>
7
+ </div>
8
+ <div class="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
9
+ <span class="rounded-full bg-slate-100 px-2 py-1">Total: <%= @preview_entries.size %></span>
10
+ <span class="rounded-full bg-green-50 px-2 py-1 text-green-700">New: <%= @preview_entries.count { |e| e[:selectable] } %></span>
11
+ <span class="rounded-full bg-amber-50 px-2 py-1 text-amber-800">Existing: <%= @preview_entries.count { |e| e[:duplicate] } %></span>
12
+ </div>
13
+ </div>
14
+
15
+ <% if @preview_entries.blank? %>
16
+ <div class="px-4 py-6 text-sm text-slate-600">
17
+ No parsed entries found. Return to the Upload step and add a different OPML file.
18
+ </div>
19
+ <div class="flex items-center justify-between gap-3 border-t border-slate-200 px-4 py-4">
20
+ <%= form_with model: import_session,
21
+ url: source_monitor.step_import_session_path(import_session, step: "preview"),
22
+ method: :patch,
23
+ data: { turbo: false } do |form| %>
24
+ <%= button_tag "Back", type: :submit, name: "import_session[next_step]", value: "upload",
25
+ class: "inline-flex items-center rounded-md border border-slate-200 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50" %>
26
+ <% end %>
27
+ <div class="flex items-center gap-3">
28
+ <%= link_to "Cancel", source_monitor.import_session_path(import_session),
29
+ data: { turbo_method: :delete, action: "confirm-navigation#disable" },
30
+ class: "inline-flex items-center rounded-md border border-slate-200 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50" %>
31
+ <span class="inline-flex items-center rounded-md bg-slate-200 px-4 py-2 text-sm font-semibold text-white">Continue</span>
32
+ </div>
33
+ </div>
34
+ <% else %>
35
+ <div class="border-b border-slate-100 px-4 py-3">
36
+ <div class="flex items-center gap-2 text-sm font-medium text-slate-700">
37
+ <% filters = { "all" => "All", "new" => "New Sources", "existing" => "Existing Sources" } %>
38
+ <% filters.each do |key, label| %>
39
+ <% active = @filter == key %>
40
+ <%= link_to label,
41
+ source_monitor.step_import_session_path(import_session, step: "preview", filter: key),
42
+ data: { turbo_frame: "import_session_step" },
43
+ class: [
44
+ "rounded-md px-3 py-2 text-xs font-semibold",
45
+ active ? "bg-blue-50 text-blue-700 border border-blue-200" : "border border-transparent text-slate-600 hover:text-slate-900 hover:border-slate-200"
46
+ ].join(" ") %>
47
+ <% end %>
48
+ </div>
49
+ </div>
50
+
51
+ <% if defined?(@selection_error) && @selection_error.present? %>
52
+ <div class="border-b border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
53
+ <%= @selection_error %>
54
+ </div>
55
+ <% end %>
56
+
57
+ <%= form_with model: import_session,
58
+ url: source_monitor.step_import_session_path(import_session, step: "preview"),
59
+ method: :patch,
60
+ data: { turbo: false, action: "confirm-navigation#disable" },
61
+ html: { class: "block" } do |form| %>
62
+ <%= hidden_field_tag :filter, @filter %>
63
+ <div class="overflow-x-auto">
64
+ <table class="min-w-full divide-y divide-slate-200 text-left text-sm" data-controller="select-all">
65
+ <thead class="bg-slate-50 text-xs font-semibold uppercase tracking-wide text-slate-500">
66
+ <tr>
67
+ <th scope="col" class="px-4 py-3 w-12 text-center">
68
+ <div class="flex items-center justify-center gap-2">
69
+ <span class="sr-only">Select all</span>
70
+ <%= check_box_tag "select_all_master", "1", @filtered_entries.all? { |e| e[:selected] || !e[:selectable] },
71
+ data: { select_all_target: "master", action: "select-all#toggleAll" },
72
+ class: "h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500" %>
73
+ </div>
74
+ </th>
75
+ <th scope="col" class="px-4 py-3">Feed URL</th>
76
+ <th scope="col" class="px-4 py-3">Title</th>
77
+ <th scope="col" class="px-4 py-3">Status</th>
78
+ </tr>
79
+ </thead>
80
+ <tbody class="divide-y divide-slate-100 text-slate-800">
81
+ <% @paginated_entries.each do |entry| %>
82
+ <% disabled = !entry[:selectable] %>
83
+ <tr class="<%= disabled ? 'bg-slate-50' : '' %>">
84
+ <td class="px-4 py-3 text-center align-middle">
85
+ <%= check_box_tag "import_session[selected_source_ids][]",
86
+ entry[:id],
87
+ entry[:selected],
88
+ disabled: disabled,
89
+ data: { select_all_target: "item", action: "select-all#toggleItem" },
90
+ class: "h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500 disabled:cursor-not-allowed disabled:border-slate-200 disabled:bg-slate-100" %>
91
+ </td>
92
+ <td class="px-4 py-3 align-top">
93
+ <div class="max-w-xl break-words font-medium text-slate-900"><%= entry[:feed_url] %></div>
94
+ <% if entry[:website_url].present? %>
95
+ <div class="mt-1 text-xs text-blue-700 truncate"><%= entry[:website_url] %></div>
96
+ <% end %>
97
+ </td>
98
+ <td class="px-4 py-3 align-top">
99
+ <div class="text-sm font-medium text-slate-900"><%= entry[:title].presence || "(No title)" %></div>
100
+ <% if entry[:status] == "malformed" && entry[:error].present? %>
101
+ <div class="mt-1 text-xs text-amber-800">Parse error: <%= entry[:error] %></div>
102
+ <% end %>
103
+ </td>
104
+ <td class="px-4 py-3 align-top">
105
+ <% if entry[:duplicate] %>
106
+ <span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-1 text-xs font-semibold text-amber-800">Already Imported</span>
107
+ <% elsif entry[:status] == "malformed" %>
108
+ <span class="inline-flex items-center rounded-full bg-red-100 px-2 py-1 text-xs font-semibold text-red-800">Malformed</span>
109
+ <% else %>
110
+ <span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-semibold text-green-800">New</span>
111
+ <% end %>
112
+ </td>
113
+ </tr>
114
+ <% end %>
115
+ <% if @paginated_entries.blank? %>
116
+ <tr>
117
+ <td colspan="4" class="px-4 py-6 text-sm text-slate-600">No entries match this filter.</td>
118
+ </tr>
119
+ <% end %>
120
+ </tbody>
121
+ </table>
122
+ </div>
123
+
124
+ <div class="flex flex-col items-center gap-3 border-t border-slate-200 px-4 py-4 sm:flex-row sm:justify-between">
125
+ <div class="text-xs text-slate-500">Page <%= @page %></div>
126
+ <div class="flex gap-2">
127
+ <% prev_params = { page: @page - 1, filter: @filter } %>
128
+ <% next_params = { page: @page + 1, filter: @filter } %>
129
+
130
+ <% if @has_previous_page %>
131
+ <%= link_to "Previous",
132
+ source_monitor.step_import_session_path(import_session, step: "preview", **prev_params),
133
+ class: "inline-flex items-center rounded-md border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50",
134
+ data: { turbo_frame: "import_session_step" } %>
135
+ <% else %>
136
+ <span class="inline-flex items-center rounded-md border border-slate-200 px-3 py-2 text-sm font-medium text-slate-300">Previous</span>
137
+ <% end %>
138
+
139
+ <% if @has_next_page %>
140
+ <%= link_to "Next",
141
+ source_monitor.step_import_session_path(import_session, step: "preview", **next_params),
142
+ class: "inline-flex items-center rounded-md border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50",
143
+ data: { turbo_frame: "import_session_step" } %>
144
+ <% else %>
145
+ <span class="inline-flex items-center rounded-md border border-slate-200 px-3 py-2 text-sm font-medium text-slate-300">Next</span>
146
+ <% end %>
147
+ </div>
148
+ </div>
149
+
150
+ <div class="flex items-center justify-between gap-3 border-t border-slate-200 px-4 py-4">
151
+ <div>
152
+ <%= button_tag "Back",
153
+ type: :submit,
154
+ name: "import_session[next_step]",
155
+ value: "upload",
156
+ class: "inline-flex items-center rounded-md border border-slate-200 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50" %>
157
+ </div>
158
+ <div class="flex items-center gap-3">
159
+ <%= link_to "Cancel", source_monitor.import_session_path(import_session),
160
+ data: { turbo_method: :delete, action: "confirm-navigation#disable" },
161
+ class: "inline-flex items-center rounded-md border border-slate-200 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50" %>
162
+ <%= button_tag "Continue",
163
+ type: :submit,
164
+ name: "import_session[next_step]",
165
+ value: "health_check",
166
+ class: "inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500" %>
167
+ </div>
168
+ </div>
169
+ <% end %>
170
+ <% end %>
171
+ </div>
172
+ </div>
@@ -0,0 +1,42 @@
1
+ <div class="space-y-6">
2
+ <div class="rounded-lg border border-slate-200 bg-white shadow-sm">
3
+ <div class="border-b border-slate-100 px-4 py-3">
4
+ <h2 class="text-lg font-semibold text-slate-900">Upload OPML file</h2>
5
+ <p class="mt-1 text-sm text-slate-600">Select an OPML file to begin. Parsing runs immediately so you can review results in Preview.</p>
6
+ </div>
7
+ <div class="px-4 py-5">
8
+ <%= form_with model: import_session,
9
+ url: source_monitor.step_import_session_path(import_session, step: "upload"),
10
+ method: :patch,
11
+ data: { turbo: false, action: "confirm-navigation#disable" },
12
+ html: { enctype: "multipart/form-data", class: "space-y-4" } do |form| %>
13
+ <% if defined?(@upload_errors) && @upload_errors.present? %>
14
+ <div class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
15
+ <ul class="list-disc space-y-1 pl-5">
16
+ <% @upload_errors.each do |message| %>
17
+ <li><%= message %></li>
18
+ <% end %>
19
+ </ul>
20
+ </div>
21
+ <% end %>
22
+ <div>
23
+ <%= form.label :opml_file, "OPML file", class: "block text-sm font-semibold text-slate-800" %>
24
+ <p class="text-xs text-slate-500">Max size and validation are handled later in the flow.</p>
25
+ <div class="mt-2 flex items-center gap-3">
26
+ <%= form.file_field :opml_file, name: :opml_file, accept: ".opml, text/xml, application/xml", class: "block w-full text-sm text-slate-700" %>
27
+ </div>
28
+ <% if import_session.opml_file_metadata.present? %>
29
+ <p class="mt-2 text-xs text-slate-500">Current upload: <%= import_session.opml_file_metadata["filename"] %></p>
30
+ <% end %>
31
+ </div>
32
+ <div class="flex items-center justify-end gap-3">
33
+ <%= link_to "Cancel", source_monitor.import_session_path(import_session),
34
+ data: { turbo_method: :delete, action: "confirm-navigation#disable" },
35
+ class: "inline-flex items-center rounded-md border border-slate-200 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50" %>
36
+ <%= hidden_field_tag :next_step, "preview", name: "import_session[next_step]" %>
37
+ <%= form.submit "Continue", class: "inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500" %>
38
+ </div>
39
+ <% end %>
40
+ </div>
41
+ </div>
42
+ </div>
@@ -1,143 +1,13 @@
1
- <%= form_with model: source, class: "space-y-6" do |form| %>
2
- <div class="space-y-4 rounded-lg border border-slate-200 bg-white px-5 py-5 shadow-sm">
3
- <div>
4
- <%= form.label :name, class: "block text-sm font-medium text-slate-800" %>
5
- <%= form.text_field :name, class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2" %>
6
- </div>
7
-
8
- <div>
9
- <%= form.label :feed_url, class: "block text-sm font-medium text-slate-800" %>
10
- <%= form.url_field :feed_url, class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2" %>
11
- </div>
12
-
13
- <div>
14
- <%= form.label :website_url, class: "block text-sm font-medium text-slate-800" %>
15
- <%= form.url_field :website_url, class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2" %>
16
- </div>
1
+ <% submit_label = local_assigns.fetch(:submit_label, source.persisted? ? "Update Source" : "Create Source") %>
2
+ <% show_submit = local_assigns.fetch(:show_submit, true) %>
3
+ <% form_options = { class: "space-y-6" }.merge(local_assigns.fetch(:form_options, {})) %>
17
4
 
18
- <label class="inline-flex items-center space-x-2">
19
- <%= form.check_box :active %>
20
- <span>Active</span>
21
- </label>
22
- </div>
5
+ <%= form_with model: source, url: local_assigns[:form_url], **form_options do |form| %>
6
+ <%= render "source_monitor/sources/form_fields", form: form %>
23
7
 
24
- <div class="space-y-4 rounded-lg border border-slate-200 bg-white px-5 py-5 shadow-sm">
8
+ <% if show_submit %>
25
9
  <div>
26
- <%= form.label :fetch_interval_minutes, "Fetch interval (minutes)", class: "block text-sm font-medium text-slate-800" %>
27
- <%= form.number_field :fetch_interval_minutes, min: 1, step: 1, class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2" %>
28
- <p class="mt-1 text-xs text-slate-500">Minimum 5 minutes recommended.</p>
10
+ <%= form.submit submit_label, class: "inline-flex items-center rounded bg-blue-600 px-4 py-2 text-white shadow hover:bg-blue-500" %>
29
11
  </div>
30
-
31
- <label class="flex items-start space-x-3 rounded-lg border border-slate-200 bg-slate-50 px-4 py-3">
32
- <%= form.check_box :adaptive_fetching_enabled, class: "mt-1" %>
33
- <span>
34
- <span class="block text-sm font-medium text-slate-800">Automatically adjust fetch interval</span>
35
- <span class="mt-1 block text-xs text-slate-500">
36
- When enabled, SourceMonitor lengthens or shortens the interval based on feed activity and failures. Disable to keep the interval fixed at the value above.
37
- </span>
38
- </span>
39
- </label>
40
-
41
- <div>
42
- <%= form.label :health_auto_pause_threshold, "Auto-pause threshold", class: "block text-sm font-medium text-slate-800" %>
43
- <%= form.number_field :health_auto_pause_threshold, min: 0, max: 1, step: 0.05, class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2" %>
44
- <p class="mt-1 text-xs text-slate-500">
45
- Optional override for this source. When the rolling success rate falls below this fraction (defaults to <%= SourceMonitor.config.health.auto_pause_threshold %>), the source is temporarily auto-paused.
46
- </p>
47
- </div>
48
- </div>
49
-
50
- <div class="rounded-lg border border-slate-200 bg-white shadow-sm">
51
- <div class="border-b border-slate-200 px-5 py-4">
52
- <h2 class="text-lg font-medium">Retention</h2>
53
- <p class="mt-1 text-xs text-slate-500">
54
- Choose how long SourceMonitor keeps items for this source. Pruning runs after every fetch so the UI always reflects active retention rules.
55
- </p>
56
- </div>
57
- <div class="grid gap-4 px-5 py-5 sm:grid-cols-2">
58
- <div>
59
- <%= form.label :items_retention_days, "Retention window (days)", class: "block text-sm font-medium text-slate-800" %>
60
- <%= form.number_field :items_retention_days, min: 0, class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2" %>
61
- <p class="mt-1 text-xs text-slate-500">
62
- Leave blank to keep historical items indefinitely. Items older than the configured window are removed automatically.
63
- </p>
64
- </div>
65
-
66
- <div>
67
- <%= form.label :max_items, "Maximum stored items", class: "block text-sm font-medium text-slate-800" %>
68
- <%= form.number_field :max_items, min: 0, class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2" %>
69
- <p class="mt-1 text-xs text-slate-500">
70
- Optional hard cap. SourceMonitor keeps the newest items and prunes the rest after each fetch.
71
- </p>
72
- </div>
73
- </div>
74
- </div>
75
-
76
- <div class="rounded-lg border border-slate-200 bg-white shadow-sm">
77
- <div class="border-b border-slate-200 px-5 py-4">
78
- <h2 class="text-lg font-medium">Feed Content Processing</h2>
79
- <p class="mt-1 text-xs text-slate-500">Control whether feed-supplied content is cleaned with Readability before storing items.</p>
80
- </div>
81
- <div class="space-y-4 px-5 py-5">
82
- <label class="inline-flex items-center space-x-2">
83
- <%= form.check_box :feed_content_readability_enabled %>
84
- <span>Process feed content with Readability</span>
85
- </label>
86
- <p class="text-xs text-slate-500">
87
- When enabled, the raw feed HTML is passed through Readability. Scraping configuration below remains independent, so you can mix-and-match feed content and scraping strategies.
88
- </p>
89
- </div>
90
- </div>
91
-
92
- <% scrape_settings = (source.scrape_settings || {}).with_indifferent_access %>
93
- <% selectors = scrape_settings[:selectors] || {} %>
94
-
95
- <div class="rounded-lg border border-slate-200 bg-white shadow-sm">
96
- <div class="border-b border-slate-200 px-5 py-4">
97
- <h2 class="text-lg font-medium">Scraping Configuration</h2>
98
- <p class="mt-1 text-xs text-slate-500">Control how content extraction runs for this source.</p>
99
- </div>
100
- <div class="space-y-5 px-5 py-5">
101
- <div class="grid gap-3 sm:grid-cols-2">
102
- <label class="inline-flex items-center space-x-2">
103
- <%= form.check_box :scraping_enabled %>
104
- <span>Scraping enabled</span>
105
- </label>
106
-
107
- <label class="inline-flex items-center space-x-2">
108
- <%= form.check_box :auto_scrape %>
109
- <span>Auto scrape after fetch</span>
110
- </label>
111
-
112
- <label class="inline-flex items-center space-x-2">
113
- <%= form.check_box :requires_javascript %>
114
- <span>Requires JavaScript</span>
115
- </label>
116
- </div>
117
-
118
- <div>
119
- <%= form.label :scraper_adapter, "Scraper adapter", class: "block text-sm font-medium text-slate-800" %>
120
- <%= form.select :scraper_adapter, [["Readability", "readability"]], {}, class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2" %>
121
- <p class="mt-1 text-xs text-slate-500">Additional adapters can be configured in future phases.</p>
122
- </div>
123
-
124
- <div class="grid gap-4 sm:grid-cols-2">
125
- <div>
126
- <label for="source_scrape_settings_selectors_content" class="block text-sm font-medium text-slate-800">Content CSS selector</label>
127
- <%= text_field_tag "source[scrape_settings][selectors][content]", selectors[:content], id: "source_scrape_settings_selectors_content", class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2", placeholder: ".article-body" %>
128
- <p class="mt-1 text-xs text-slate-500">Optional. Overrides Readability extraction target.</p>
129
- </div>
130
-
131
- <div>
132
- <label for="source_scrape_settings_selectors_title" class="block text-sm font-medium text-slate-800">Title CSS selector</label>
133
- <%= text_field_tag "source[scrape_settings][selectors][title]", selectors[:title], id: "source_scrape_settings_selectors_title", class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2", placeholder: "h1.article-title" %>
134
- <p class="mt-1 text-xs text-slate-500">Optional. Leave blank to use feed-provided title.</p>
135
- </div>
136
- </div>
137
- </div>
138
- </div>
139
-
140
- <div>
141
- <%= form.submit (source.persisted? ? "Update Source" : "Create Source"), class: "inline-flex items-center rounded bg-blue-600 px-4 py-2 text-white shadow hover:bg-blue-500" %>
142
- </div>
12
+ <% end %>
143
13
  <% end %>