data_porter 0.1.0 → 0.4.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 (168) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/README.md +63 -386
  4. data/ROADMAP.md +89 -0
  5. data/app/assets/javascripts/data_porter/stimulus.min.js +2 -0
  6. data/app/assets/javascripts/data_porter/turbo.min.js +29 -0
  7. data/app/assets/stylesheets/data_porter/alerts.css +25 -0
  8. data/app/assets/stylesheets/data_porter/application.css +12 -646
  9. data/app/assets/stylesheets/data_porter/badges.css +73 -0
  10. data/app/assets/stylesheets/data_porter/base.css +56 -0
  11. data/app/assets/stylesheets/data_porter/cards.css +60 -0
  12. data/app/assets/stylesheets/data_porter/layout.css +128 -0
  13. data/app/assets/stylesheets/data_porter/mapping.css +79 -0
  14. data/app/assets/stylesheets/data_porter/modal.css +49 -0
  15. data/app/assets/stylesheets/data_porter/preview.css +24 -0
  16. data/app/assets/stylesheets/data_porter/progress.css +37 -0
  17. data/app/assets/stylesheets/data_porter/table.css +45 -0
  18. data/app/controllers/data_porter/imports_controller.rb +74 -10
  19. data/app/controllers/data_porter/mapping_templates_controller.rb +85 -0
  20. data/app/javascript/data_porter/mapping_controller.js +86 -0
  21. data/app/javascript/data_porter/progress_controller.js +1 -1
  22. data/app/javascript/data_porter/template_form_controller.js +46 -0
  23. data/app/jobs/data_porter/extract_headers_job.rb +12 -0
  24. data/app/models/data_porter/data_import.rb +8 -2
  25. data/app/models/data_porter/mapping_template.rb +15 -0
  26. data/app/views/data_porter/imports/index.html.erb +9 -8
  27. data/app/views/data_porter/imports/new.html.erb +10 -4
  28. data/app/views/data_porter/imports/show.html.erb +41 -13
  29. data/app/views/data_porter/mapping_templates/_form.html.erb +40 -0
  30. data/app/views/data_porter/mapping_templates/edit.html.erb +11 -0
  31. data/app/views/data_porter/mapping_templates/index.html.erb +42 -0
  32. data/app/views/data_porter/mapping_templates/new.html.erb +11 -0
  33. data/app/views/layouts/data_porter/application.html.erb +162 -0
  34. data/config/routes.rb +3 -0
  35. data/docs/CONFIGURATION.md +81 -0
  36. data/docs/MAPPING.md +44 -0
  37. data/docs/SOURCES.md +94 -0
  38. data/docs/TARGETS.md +176 -0
  39. data/docs/screenshots/mapping.jpg +0 -0
  40. data/lib/data_porter/components/mapping/column_row.rb +52 -0
  41. data/lib/data_porter/components/mapping/form.rb +127 -0
  42. data/lib/data_porter/components/mapping/template_select.rb +35 -0
  43. data/lib/data_porter/components/preview/results_summary.rb +21 -0
  44. data/lib/data_porter/components/preview/summary_cards.rb +32 -0
  45. data/lib/data_porter/components/preview/table.rb +56 -0
  46. data/lib/data_porter/components/progress/bar.rb +35 -0
  47. data/lib/data_porter/components/shared/failure_alert.rb +22 -0
  48. data/lib/data_porter/components/shared/status_badge.rb +18 -0
  49. data/lib/data_porter/components.rb +9 -6
  50. data/lib/data_porter/configuration.rb +1 -1
  51. data/lib/data_porter/engine.rb +7 -1
  52. data/lib/data_porter/orchestrator.rb +21 -1
  53. data/lib/data_porter/sources/base.rb +18 -3
  54. data/lib/data_porter/sources/csv.rb +5 -0
  55. data/lib/data_porter/sources/xlsx.rb +76 -0
  56. data/lib/data_porter/sources.rb +3 -1
  57. data/lib/data_porter/version.rb +1 -1
  58. data/lib/generators/data_porter/install/install_generator.rb +4 -0
  59. data/lib/generators/data_porter/install/templates/create_data_porter_mapping_templates.rb.erb +16 -0
  60. data/lib/generators/data_porter/install/templates/initializer.rb +1 -1
  61. metadata +72 -135
  62. data/.claude/commands/blog-status.md +0 -10
  63. data/.claude/commands/blog.md +0 -109
  64. data/.claude/commands/task-done.md +0 -27
  65. data/.claude/commands/tm/add-dependency.md +0 -58
  66. data/.claude/commands/tm/add-subtask.md +0 -79
  67. data/.claude/commands/tm/add-task.md +0 -81
  68. data/.claude/commands/tm/analyze-complexity.md +0 -124
  69. data/.claude/commands/tm/analyze-project.md +0 -100
  70. data/.claude/commands/tm/auto-implement-tasks.md +0 -100
  71. data/.claude/commands/tm/command-pipeline.md +0 -80
  72. data/.claude/commands/tm/complexity-report.md +0 -120
  73. data/.claude/commands/tm/convert-task-to-subtask.md +0 -74
  74. data/.claude/commands/tm/expand-all-tasks.md +0 -52
  75. data/.claude/commands/tm/expand-task.md +0 -52
  76. data/.claude/commands/tm/fix-dependencies.md +0 -82
  77. data/.claude/commands/tm/help.md +0 -101
  78. data/.claude/commands/tm/init-project-quick.md +0 -49
  79. data/.claude/commands/tm/init-project.md +0 -53
  80. data/.claude/commands/tm/install-taskmaster.md +0 -118
  81. data/.claude/commands/tm/learn.md +0 -106
  82. data/.claude/commands/tm/list-tasks-by-status.md +0 -42
  83. data/.claude/commands/tm/list-tasks-with-subtasks.md +0 -30
  84. data/.claude/commands/tm/list-tasks.md +0 -46
  85. data/.claude/commands/tm/next-task.md +0 -69
  86. data/.claude/commands/tm/parse-prd-with-research.md +0 -51
  87. data/.claude/commands/tm/parse-prd.md +0 -52
  88. data/.claude/commands/tm/project-status.md +0 -67
  89. data/.claude/commands/tm/quick-install-taskmaster.md +0 -23
  90. data/.claude/commands/tm/remove-all-subtasks.md +0 -94
  91. data/.claude/commands/tm/remove-dependency.md +0 -65
  92. data/.claude/commands/tm/remove-subtask.md +0 -87
  93. data/.claude/commands/tm/remove-subtasks.md +0 -89
  94. data/.claude/commands/tm/remove-task.md +0 -110
  95. data/.claude/commands/tm/setup-models.md +0 -52
  96. data/.claude/commands/tm/show-task.md +0 -85
  97. data/.claude/commands/tm/smart-workflow.md +0 -58
  98. data/.claude/commands/tm/sync-readme.md +0 -120
  99. data/.claude/commands/tm/tm-main.md +0 -147
  100. data/.claude/commands/tm/to-cancelled.md +0 -58
  101. data/.claude/commands/tm/to-deferred.md +0 -50
  102. data/.claude/commands/tm/to-done.md +0 -47
  103. data/.claude/commands/tm/to-in-progress.md +0 -39
  104. data/.claude/commands/tm/to-pending.md +0 -35
  105. data/.claude/commands/tm/to-review.md +0 -43
  106. data/.claude/commands/tm/update-single-task.md +0 -122
  107. data/.claude/commands/tm/update-task.md +0 -75
  108. data/.claude/commands/tm/update-tasks-from-id.md +0 -111
  109. data/.claude/commands/tm/validate-dependencies.md +0 -72
  110. data/.claude/commands/tm/view-models.md +0 -52
  111. data/.env.example +0 -12
  112. data/.mcp.json +0 -24
  113. data/.taskmaster/CLAUDE.md +0 -435
  114. data/.taskmaster/config.json +0 -44
  115. data/.taskmaster/docs/prd.txt +0 -2044
  116. data/.taskmaster/state.json +0 -6
  117. data/.taskmaster/tasks/task_001.md +0 -19
  118. data/.taskmaster/tasks/task_002.md +0 -19
  119. data/.taskmaster/tasks/task_003.md +0 -19
  120. data/.taskmaster/tasks/task_004.md +0 -19
  121. data/.taskmaster/tasks/task_005.md +0 -19
  122. data/.taskmaster/tasks/task_006.md +0 -19
  123. data/.taskmaster/tasks/task_007.md +0 -19
  124. data/.taskmaster/tasks/task_008.md +0 -19
  125. data/.taskmaster/tasks/task_009.md +0 -19
  126. data/.taskmaster/tasks/task_010.md +0 -19
  127. data/.taskmaster/tasks/task_011.md +0 -19
  128. data/.taskmaster/tasks/task_012.md +0 -19
  129. data/.taskmaster/tasks/task_013.md +0 -19
  130. data/.taskmaster/tasks/task_014.md +0 -19
  131. data/.taskmaster/tasks/task_015.md +0 -19
  132. data/.taskmaster/tasks/task_016.md +0 -19
  133. data/.taskmaster/tasks/task_017.md +0 -19
  134. data/.taskmaster/tasks/task_018.md +0 -19
  135. data/.taskmaster/tasks/task_019.md +0 -19
  136. data/.taskmaster/tasks/task_020.md +0 -19
  137. data/.taskmaster/tasks/tasks.json +0 -299
  138. data/.taskmaster/templates/example_prd.txt +0 -47
  139. data/.taskmaster/templates/example_prd_rpg.txt +0 -511
  140. data/CLAUDE.md +0 -65
  141. data/config/database.yml +0 -3
  142. data/docs/SPEC.md +0 -2012
  143. data/docs/UI.md +0 -32
  144. data/docs/blog/001-why-build-a-data-import-engine.md +0 -166
  145. data/docs/blog/002-scaffolding-a-rails-engine.md +0 -188
  146. data/docs/blog/003-configuration-dsl.md +0 -222
  147. data/docs/blog/004-store-model-jsonb.md +0 -237
  148. data/docs/blog/005-target-dsl.md +0 -284
  149. data/docs/blog/006-parsing-csv-sources.md +0 -300
  150. data/docs/blog/007-orchestrator.md +0 -247
  151. data/docs/blog/008-actioncable-stimulus.md +0 -376
  152. data/docs/blog/009-phlex-ui-components.md +0 -446
  153. data/docs/blog/010-controllers-routing.md +0 -374
  154. data/docs/blog/011-generators.md +0 -364
  155. data/docs/blog/012-json-api-sources.md +0 -323
  156. data/docs/blog/013-testing-rails-engine.md +0 -618
  157. data/docs/blog/014-dry-run.md +0 -307
  158. data/docs/blog/015-publishing-retro.md +0 -264
  159. data/docs/blog/016-erb-view-templates.md +0 -431
  160. data/docs/blog/017-showcase-final-retro.md +0 -220
  161. data/docs/blog/BACKLOG.md +0 -8
  162. data/docs/blog/SERIES.md +0 -154
  163. data/lib/data_porter/components/failure_alert.rb +0 -20
  164. data/lib/data_porter/components/preview_table.rb +0 -54
  165. data/lib/data_porter/components/progress_bar.rb +0 -33
  166. data/lib/data_porter/components/results_summary.rb +0 -19
  167. data/lib/data_porter/components/status_badge.rb +0 -16
  168. data/lib/data_porter/components/summary_cards.rb +0 -30
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module Components
5
+ module Preview
6
+ class Table < Base
7
+ def initialize(columns:, records:)
8
+ super()
9
+ @columns = columns
10
+ @records = records
11
+ end
12
+
13
+ def view_template
14
+ div(class: "dp-preview-table") do
15
+ table(class: "dp-table") do
16
+ render_header
17
+ render_body
18
+ end
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def render_header
25
+ thead do
26
+ tr do
27
+ th { "#" }
28
+ th { "Status" }
29
+ @columns.each { |col| th { col.label } }
30
+ th { "Errors" }
31
+ end
32
+ end
33
+ end
34
+
35
+ def render_body
36
+ tbody do
37
+ @records.each { |record| render_row(record) }
38
+ end
39
+ end
40
+
41
+ def render_row(record)
42
+ tr(class: "dp-row--#{record.status}") do
43
+ td { record.line_number.to_s }
44
+ td { record.status }
45
+ @columns.each { |col| td { record.data[col.name.to_s].to_s } }
46
+ td(class: "dp-errors") { error_messages(record) }
47
+ end
48
+ end
49
+
50
+ def error_messages(record)
51
+ record.errors_list.map(&:message).join(", ")
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module Components
5
+ module Progress
6
+ class Bar < Base
7
+ def initialize(import_id:)
8
+ super()
9
+ @import_id = import_id
10
+ end
11
+
12
+ def view_template
13
+ div(class: "dp-progress", **stimulus_controller_attrs) do
14
+ render_bar
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def render_bar
21
+ div(class: "dp-progress-bar", data_data_porter__progress_target: "bar", style: "width: 0%") do
22
+ span(data_data_porter__progress_target: "text") { "0%" }
23
+ end
24
+ end
25
+
26
+ def stimulus_controller_attrs
27
+ {
28
+ data_controller: "data-porter--progress",
29
+ data_data_porter__progress_id_value: @import_id.to_s
30
+ }
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module Components
5
+ module Shared
6
+ class FailureAlert < Base
7
+ def initialize(report:)
8
+ super()
9
+ @report = report
10
+ end
11
+
12
+ def view_template
13
+ div(class: "dp-alert dp-alert--danger") do
14
+ @report.error_reports.each do |err|
15
+ p { err.message }
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module Components
5
+ module Shared
6
+ class StatusBadge < Base
7
+ def initialize(status:)
8
+ super()
9
+ @status = status.to_s
10
+ end
11
+
12
+ def view_template
13
+ span(class: "dp-badge dp-badge--#{@status}") { @status.capitalize }
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -1,12 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "components/base"
4
- require_relative "components/status_badge"
5
- require_relative "components/summary_cards"
6
- require_relative "components/preview_table"
7
- require_relative "components/progress_bar"
8
- require_relative "components/results_summary"
9
- require_relative "components/failure_alert"
4
+ require_relative "components/shared/status_badge"
5
+ require_relative "components/shared/failure_alert"
6
+ require_relative "components/preview/table"
7
+ require_relative "components/preview/summary_cards"
8
+ require_relative "components/preview/results_summary"
9
+ require_relative "components/progress/bar"
10
+ require_relative "components/mapping/template_select"
11
+ require_relative "components/mapping/column_row"
12
+ require_relative "components/mapping/form"
10
13
 
11
14
  module DataPorter
12
15
  module Components
@@ -18,7 +18,7 @@ module DataPorter
18
18
  @cable_channel_prefix = "data_porter"
19
19
  @context_builder = nil
20
20
  @preview_limit = 500
21
- @enabled_sources = %i[csv json api]
21
+ @enabled_sources = %i[csv json api xlsx]
22
22
  @scope = nil
23
23
  end
24
24
  end
@@ -5,7 +5,13 @@ module DataPorter
5
5
  isolate_namespace DataPorter
6
6
 
7
7
  initializer "data_porter.assets.precompile" do |app|
8
- app.config.assets.precompile += %w[data_porter/application.css] if app.config.respond_to?(:assets)
8
+ if app.config.respond_to?(:assets)
9
+ app.config.assets.precompile += %w[
10
+ data_porter/application.css
11
+ data_porter/turbo.min.js
12
+ data_porter/stimulus.min.js
13
+ ]
14
+ end
9
15
  end
10
16
 
11
17
  config.to_prepare do
@@ -8,6 +8,16 @@ module DataPorter
8
8
  @source_options = { content: content }.compact
9
9
  end
10
10
 
11
+ def extract_headers!
12
+ @data_import.extracting_headers!
13
+ source = build_source
14
+ headers = source.headers
15
+ store_headers(headers)
16
+ @data_import.update!(status: :mapping)
17
+ rescue StandardError => e
18
+ handle_failure(e)
19
+ end
20
+
11
21
  def parse!
12
22
  @data_import.parsing!
13
23
  records = build_records
@@ -37,8 +47,18 @@ module DataPorter
37
47
 
38
48
  private
39
49
 
50
+ def build_source
51
+ @data_import.source_class.new(@data_import, **@source_options)
52
+ end
53
+
54
+ def store_headers(headers)
55
+ config = @data_import.config || {}
56
+ config["file_headers"] = headers
57
+ @data_import.update!(config: config)
58
+ end
59
+
40
60
  def build_records
41
- source = @data_import.source_class.new(@data_import, **@source_options)
61
+ source = build_source
42
62
  raw_rows = source.fetch
43
63
  columns = @target.class._columns || []
44
64
  validator = RecordValidator.new(columns)
@@ -15,10 +15,25 @@ module DataPorter
15
15
  private
16
16
 
17
17
  def apply_csv_mapping(row)
18
- mappings = @target_class._csv_mappings
19
- return auto_map(row) if mappings.nil? || mappings.empty?
18
+ return user_map(row) if user_mapping.any?
20
19
 
21
- explicit_map(row, mappings)
20
+ code_mappings = @target_class._csv_mappings
21
+ return explicit_map(row, code_mappings) if code_mappings&.any?
22
+
23
+ auto_map(row)
24
+ end
25
+
26
+ def user_mapping
27
+ config = @data_import.config
28
+ return {} unless config.is_a?(Hash)
29
+
30
+ config.fetch("column_mapping", {})
31
+ end
32
+
33
+ def user_map(row)
34
+ user_mapping.each_with_object({}) do |(header, column), hash|
35
+ hash[column.to_sym] = row[header]
36
+ end
22
37
  end
23
38
 
24
39
  def explicit_map(row, mappings)
@@ -10,6 +10,11 @@ module DataPorter
10
10
  @content = content
11
11
  end
12
12
 
13
+ def headers
14
+ first_line = csv_content.lines.first
15
+ ::CSV.parse_line(first_line, **extra_options).map(&:to_s)
16
+ end
17
+
13
18
  def fetch
14
19
  rows = []
15
20
  ::CSV.parse(csv_content, **csv_options) do |row|
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "creek"
4
+ require "tempfile"
5
+
6
+ module DataPorter
7
+ module Sources
8
+ class Xlsx < Base
9
+ def initialize(data_import, file_path: nil)
10
+ super(data_import)
11
+ @file_path = file_path
12
+ end
13
+
14
+ def headers
15
+ sheet = target_sheet
16
+ first_row = sheet.simple_rows.first
17
+ first_row&.values&.map(&:to_s) || []
18
+ ensure
19
+ cleanup
20
+ end
21
+
22
+ def fetch
23
+ rows = parse_sheet(target_sheet)
24
+ rows.map { |row| apply_csv_mapping(row) }
25
+ ensure
26
+ cleanup
27
+ end
28
+
29
+ private
30
+
31
+ def target_sheet
32
+ creek = Creek::Book.new(xlsx_path)
33
+ creek.sheets[sheet_index]
34
+ end
35
+
36
+ def parse_sheet(sheet)
37
+ rows = sheet.simple_rows.to_a
38
+ return [] if rows.size <= 1
39
+
40
+ headers = rows.first.values.map(&:to_s)
41
+ rows.drop(1).map { |row| build_row(headers, row) }
42
+ end
43
+
44
+ def build_row(headers, row)
45
+ values = row.values.map { |v| v&.to_s }
46
+ headers.zip(values).to_h
47
+ end
48
+
49
+ def xlsx_path
50
+ @file_path || download_to_tempfile
51
+ end
52
+
53
+ def download_to_tempfile
54
+ @tempfile = Tempfile.new(["data_porter", ".xlsx"])
55
+ @tempfile.binmode
56
+ @tempfile.write(@data_import.file.download)
57
+ @tempfile.rewind
58
+ @tempfile.path
59
+ end
60
+
61
+ def sheet_index
62
+ config = @data_import.config
63
+ return 0 unless config.is_a?(Hash)
64
+
65
+ config.fetch("sheet_index", 0).to_i
66
+ end
67
+
68
+ def cleanup
69
+ return unless @tempfile
70
+
71
+ @tempfile.close
72
+ @tempfile.unlink
73
+ end
74
+ end
75
+ end
76
+ end
@@ -4,13 +4,15 @@ require_relative "sources/base"
4
4
  require_relative "sources/csv"
5
5
  require_relative "sources/json"
6
6
  require_relative "sources/api"
7
+ require_relative "sources/xlsx"
7
8
 
8
9
  module DataPorter
9
10
  module Sources
10
11
  REGISTRY = {
11
12
  api: Api,
12
13
  csv: Csv,
13
- json: Json
14
+ json: Json,
15
+ xlsx: Xlsx
14
16
  }.freeze
15
17
 
16
18
  def self.resolve(type)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DataPorter
4
- VERSION = "0.1.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -15,6 +15,10 @@ module DataPorter
15
15
  "create_data_porter_imports.rb.erb",
16
16
  "db/migrate/create_data_porter_imports.rb"
17
17
  )
18
+ migration_template(
19
+ "create_data_porter_mapping_templates.rb.erb",
20
+ "db/migrate/create_data_porter_mapping_templates.rb"
21
+ )
18
22
  end
19
23
 
20
24
  def copy_initializer
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateDataPorterMappingTemplates < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ def change
5
+ create_table :data_porter_mapping_templates do |t|
6
+ t.string :target_key, null: false
7
+ t.string :name, null: false
8
+ t.jsonb :mapping, null: false, default: {}
9
+
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :data_porter_mapping_templates, :target_key
14
+ add_index :data_porter_mapping_templates, %i[target_key name], unique: true
15
+ end
16
+ end
@@ -26,5 +26,5 @@ DataPorter.configure do |config|
26
26
  # config.preview_limit = 500
27
27
 
28
28
  # Enabled source types.
29
- # config.enabled_sources = %i[csv json api]
29
+ # config.enabled_sources = %i[csv json xlsx api]
30
30
  end