data_porter 0.1.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 (159) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/commands/blog-status.md +10 -0
  3. data/.claude/commands/blog.md +109 -0
  4. data/.claude/commands/task-done.md +27 -0
  5. data/.claude/commands/tm/add-dependency.md +58 -0
  6. data/.claude/commands/tm/add-subtask.md +79 -0
  7. data/.claude/commands/tm/add-task.md +81 -0
  8. data/.claude/commands/tm/analyze-complexity.md +124 -0
  9. data/.claude/commands/tm/analyze-project.md +100 -0
  10. data/.claude/commands/tm/auto-implement-tasks.md +100 -0
  11. data/.claude/commands/tm/command-pipeline.md +80 -0
  12. data/.claude/commands/tm/complexity-report.md +120 -0
  13. data/.claude/commands/tm/convert-task-to-subtask.md +74 -0
  14. data/.claude/commands/tm/expand-all-tasks.md +52 -0
  15. data/.claude/commands/tm/expand-task.md +52 -0
  16. data/.claude/commands/tm/fix-dependencies.md +82 -0
  17. data/.claude/commands/tm/help.md +101 -0
  18. data/.claude/commands/tm/init-project-quick.md +49 -0
  19. data/.claude/commands/tm/init-project.md +53 -0
  20. data/.claude/commands/tm/install-taskmaster.md +118 -0
  21. data/.claude/commands/tm/learn.md +106 -0
  22. data/.claude/commands/tm/list-tasks-by-status.md +42 -0
  23. data/.claude/commands/tm/list-tasks-with-subtasks.md +30 -0
  24. data/.claude/commands/tm/list-tasks.md +46 -0
  25. data/.claude/commands/tm/next-task.md +69 -0
  26. data/.claude/commands/tm/parse-prd-with-research.md +51 -0
  27. data/.claude/commands/tm/parse-prd.md +52 -0
  28. data/.claude/commands/tm/project-status.md +67 -0
  29. data/.claude/commands/tm/quick-install-taskmaster.md +23 -0
  30. data/.claude/commands/tm/remove-all-subtasks.md +94 -0
  31. data/.claude/commands/tm/remove-dependency.md +65 -0
  32. data/.claude/commands/tm/remove-subtask.md +87 -0
  33. data/.claude/commands/tm/remove-subtasks.md +89 -0
  34. data/.claude/commands/tm/remove-task.md +110 -0
  35. data/.claude/commands/tm/setup-models.md +52 -0
  36. data/.claude/commands/tm/show-task.md +85 -0
  37. data/.claude/commands/tm/smart-workflow.md +58 -0
  38. data/.claude/commands/tm/sync-readme.md +120 -0
  39. data/.claude/commands/tm/tm-main.md +147 -0
  40. data/.claude/commands/tm/to-cancelled.md +58 -0
  41. data/.claude/commands/tm/to-deferred.md +50 -0
  42. data/.claude/commands/tm/to-done.md +47 -0
  43. data/.claude/commands/tm/to-in-progress.md +39 -0
  44. data/.claude/commands/tm/to-pending.md +35 -0
  45. data/.claude/commands/tm/to-review.md +43 -0
  46. data/.claude/commands/tm/update-single-task.md +122 -0
  47. data/.claude/commands/tm/update-task.md +75 -0
  48. data/.claude/commands/tm/update-tasks-from-id.md +111 -0
  49. data/.claude/commands/tm/validate-dependencies.md +72 -0
  50. data/.claude/commands/tm/view-models.md +52 -0
  51. data/.env.example +12 -0
  52. data/.mcp.json +24 -0
  53. data/.taskmaster/CLAUDE.md +435 -0
  54. data/.taskmaster/config.json +44 -0
  55. data/.taskmaster/docs/prd.txt +2044 -0
  56. data/.taskmaster/state.json +6 -0
  57. data/.taskmaster/tasks/task_001.md +19 -0
  58. data/.taskmaster/tasks/task_002.md +19 -0
  59. data/.taskmaster/tasks/task_003.md +19 -0
  60. data/.taskmaster/tasks/task_004.md +19 -0
  61. data/.taskmaster/tasks/task_005.md +19 -0
  62. data/.taskmaster/tasks/task_006.md +19 -0
  63. data/.taskmaster/tasks/task_007.md +19 -0
  64. data/.taskmaster/tasks/task_008.md +19 -0
  65. data/.taskmaster/tasks/task_009.md +19 -0
  66. data/.taskmaster/tasks/task_010.md +19 -0
  67. data/.taskmaster/tasks/task_011.md +19 -0
  68. data/.taskmaster/tasks/task_012.md +19 -0
  69. data/.taskmaster/tasks/task_013.md +19 -0
  70. data/.taskmaster/tasks/task_014.md +19 -0
  71. data/.taskmaster/tasks/task_015.md +19 -0
  72. data/.taskmaster/tasks/task_016.md +19 -0
  73. data/.taskmaster/tasks/task_017.md +19 -0
  74. data/.taskmaster/tasks/task_018.md +19 -0
  75. data/.taskmaster/tasks/task_019.md +19 -0
  76. data/.taskmaster/tasks/task_020.md +19 -0
  77. data/.taskmaster/tasks/tasks.json +299 -0
  78. data/.taskmaster/templates/example_prd.txt +47 -0
  79. data/.taskmaster/templates/example_prd_rpg.txt +511 -0
  80. data/CHANGELOG.md +29 -0
  81. data/CLAUDE.md +65 -0
  82. data/CODE_OF_CONDUCT.md +10 -0
  83. data/CONTRIBUTING.md +49 -0
  84. data/LICENSE +21 -0
  85. data/README.md +463 -0
  86. data/Rakefile +12 -0
  87. data/app/assets/stylesheets/data_porter/application.css +646 -0
  88. data/app/channels/data_porter/import_channel.rb +10 -0
  89. data/app/controllers/data_porter/imports_controller.rb +68 -0
  90. data/app/javascript/data_porter/progress_controller.js +33 -0
  91. data/app/jobs/data_porter/dry_run_job.rb +12 -0
  92. data/app/jobs/data_porter/import_job.rb +12 -0
  93. data/app/jobs/data_porter/parse_job.rb +12 -0
  94. data/app/models/data_porter/data_import.rb +49 -0
  95. data/app/views/data_porter/imports/index.html.erb +142 -0
  96. data/app/views/data_porter/imports/new.html.erb +88 -0
  97. data/app/views/data_porter/imports/show.html.erb +49 -0
  98. data/config/database.yml +3 -0
  99. data/config/routes.rb +12 -0
  100. data/docs/SPEC.md +2012 -0
  101. data/docs/UI.md +32 -0
  102. data/docs/blog/001-why-build-a-data-import-engine.md +166 -0
  103. data/docs/blog/002-scaffolding-a-rails-engine.md +188 -0
  104. data/docs/blog/003-configuration-dsl.md +222 -0
  105. data/docs/blog/004-store-model-jsonb.md +237 -0
  106. data/docs/blog/005-target-dsl.md +284 -0
  107. data/docs/blog/006-parsing-csv-sources.md +300 -0
  108. data/docs/blog/007-orchestrator.md +247 -0
  109. data/docs/blog/008-actioncable-stimulus.md +376 -0
  110. data/docs/blog/009-phlex-ui-components.md +446 -0
  111. data/docs/blog/010-controllers-routing.md +374 -0
  112. data/docs/blog/011-generators.md +364 -0
  113. data/docs/blog/012-json-api-sources.md +323 -0
  114. data/docs/blog/013-testing-rails-engine.md +618 -0
  115. data/docs/blog/014-dry-run.md +307 -0
  116. data/docs/blog/015-publishing-retro.md +264 -0
  117. data/docs/blog/016-erb-view-templates.md +431 -0
  118. data/docs/blog/017-showcase-final-retro.md +220 -0
  119. data/docs/blog/BACKLOG.md +8 -0
  120. data/docs/blog/SERIES.md +154 -0
  121. data/docs/screenshots/index-with-previewing.jpg +0 -0
  122. data/docs/screenshots/index.jpg +0 -0
  123. data/docs/screenshots/modal-new-import.jpg +0 -0
  124. data/docs/screenshots/preview.jpg +0 -0
  125. data/lib/data_porter/broadcaster.rb +29 -0
  126. data/lib/data_porter/components/base.rb +10 -0
  127. data/lib/data_porter/components/failure_alert.rb +20 -0
  128. data/lib/data_porter/components/preview_table.rb +54 -0
  129. data/lib/data_porter/components/progress_bar.rb +33 -0
  130. data/lib/data_porter/components/results_summary.rb +19 -0
  131. data/lib/data_porter/components/status_badge.rb +16 -0
  132. data/lib/data_porter/components/summary_cards.rb +30 -0
  133. data/lib/data_porter/components.rb +14 -0
  134. data/lib/data_porter/configuration.rb +25 -0
  135. data/lib/data_porter/dsl/api_config.rb +25 -0
  136. data/lib/data_porter/dsl/column.rb +17 -0
  137. data/lib/data_porter/engine.rb +15 -0
  138. data/lib/data_porter/orchestrator.rb +141 -0
  139. data/lib/data_porter/record_validator.rb +32 -0
  140. data/lib/data_porter/registry.rb +33 -0
  141. data/lib/data_porter/sources/api.rb +49 -0
  142. data/lib/data_porter/sources/base.rb +35 -0
  143. data/lib/data_porter/sources/csv.rb +43 -0
  144. data/lib/data_porter/sources/json.rb +45 -0
  145. data/lib/data_porter/sources.rb +20 -0
  146. data/lib/data_porter/store_models/error.rb +13 -0
  147. data/lib/data_porter/store_models/import_record.rb +52 -0
  148. data/lib/data_porter/store_models/report.rb +21 -0
  149. data/lib/data_porter/target.rb +89 -0
  150. data/lib/data_porter/type_validator.rb +46 -0
  151. data/lib/data_porter/version.rb +5 -0
  152. data/lib/data_porter.rb +32 -0
  153. data/lib/generators/data_porter/install/install_generator.rb +33 -0
  154. data/lib/generators/data_porter/install/templates/create_data_porter_imports.rb.erb +21 -0
  155. data/lib/generators/data_porter/install/templates/initializer.rb +30 -0
  156. data/lib/generators/data_porter/target/target_generator.rb +44 -0
  157. data/lib/generators/data_porter/target/templates/target.rb.tt +20 -0
  158. data/sig/data_porter.rbs +4 -0
  159. metadata +274 -0
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ class TargetNotFound < Error; end
5
+
6
+ module Registry
7
+ @targets = {}
8
+
9
+ class << self
10
+ def register(key, klass)
11
+ @targets[key.to_sym] = klass
12
+ end
13
+
14
+ def find(key)
15
+ @targets.fetch(key.to_sym) { raise TargetNotFound, "Target '#{key}' not found" }
16
+ end
17
+
18
+ def available
19
+ @targets.map do |key, klass|
20
+ { key: key, label: klass._label, icon: klass._icon }
21
+ end
22
+ end
23
+
24
+ def refresh!
25
+ clear
26
+ end
27
+
28
+ def clear
29
+ @targets = {}
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+
6
+ module DataPorter
7
+ module Sources
8
+ class Api < Base
9
+ def fetch
10
+ api = @target_class._api_config
11
+ response = perform_request(api)
12
+ parsed = ::JSON.parse(response.body)
13
+ records = extract_records(parsed, api)
14
+
15
+ Array(records).map do |hash|
16
+ hash.transform_keys { |k| k.parameterize(separator: "_").to_sym }
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def perform_request(api)
23
+ url = resolve_endpoint(api)
24
+ headers = resolve_headers(api)
25
+ uri = URI(url)
26
+
27
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
28
+ request = Net::HTTP::Get.new(uri)
29
+ headers.each { |k, v| request[k] = v }
30
+ http.request(request)
31
+ end
32
+ end
33
+
34
+ def resolve_endpoint(api)
35
+ params = @data_import.config.symbolize_keys
36
+ api.endpoint.is_a?(Proc) ? api.endpoint.call(params) : api.endpoint
37
+ end
38
+
39
+ def resolve_headers(api)
40
+ api.headers.is_a?(Proc) ? api.headers.call : (api.headers || {})
41
+ end
42
+
43
+ def extract_records(parsed, api)
44
+ root = api.response_root
45
+ root ? parsed[root.to_s] : parsed
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module Sources
5
+ class Base
6
+ def initialize(data_import, **)
7
+ @data_import = data_import
8
+ @target_class = data_import.target_class
9
+ end
10
+
11
+ def fetch
12
+ raise NotImplementedError
13
+ end
14
+
15
+ private
16
+
17
+ def apply_csv_mapping(row)
18
+ mappings = @target_class._csv_mappings
19
+ return auto_map(row) if mappings.nil? || mappings.empty?
20
+
21
+ explicit_map(row, mappings)
22
+ end
23
+
24
+ def explicit_map(row, mappings)
25
+ mappings.each_with_object({}) do |(header, column), hash|
26
+ hash[column] = row[header]
27
+ end
28
+ end
29
+
30
+ def auto_map(row)
31
+ row.to_h.transform_keys { |k| k.parameterize(separator: "_").to_sym }
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module DataPorter
6
+ module Sources
7
+ class Csv < Base
8
+ def initialize(data_import, content: nil)
9
+ super(data_import)
10
+ @content = content
11
+ end
12
+
13
+ def fetch
14
+ rows = []
15
+ ::CSV.parse(csv_content, **csv_options) do |row|
16
+ rows << apply_csv_mapping(row)
17
+ end
18
+ rows
19
+ end
20
+
21
+ private
22
+
23
+ def csv_content
24
+ @content || download_file
25
+ end
26
+
27
+ def download_file
28
+ @data_import.file.download.force_encoding("UTF-8")
29
+ end
30
+
31
+ def csv_options
32
+ { headers: true }.merge(extra_options)
33
+ end
34
+
35
+ def extra_options
36
+ config = @data_import.config
37
+ return {} unless config.is_a?(Hash)
38
+
39
+ config.symbolize_keys.slice(:col_sep, :encoding)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module DataPorter
6
+ module Sources
7
+ class Json < Base
8
+ def initialize(data_import, content: nil)
9
+ super(data_import)
10
+ @content = content
11
+ end
12
+
13
+ def fetch
14
+ parsed = ::JSON.parse(json_content)
15
+ records = extract_records(parsed)
16
+
17
+ Array(records).map do |hash|
18
+ hash.transform_keys { |k| k.parameterize(separator: "_").to_sym }
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def json_content
25
+ @content || config_raw_json || download_file
26
+ end
27
+
28
+ def config_raw_json
29
+ config = @data_import.config
30
+ config["raw_json"] if config.is_a?(Hash)
31
+ end
32
+
33
+ def download_file
34
+ @data_import.file.download
35
+ end
36
+
37
+ def extract_records(parsed)
38
+ root = @target_class._json_root
39
+ return parsed unless root
40
+
41
+ parsed.dig(*root.split("."))
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sources/base"
4
+ require_relative "sources/csv"
5
+ require_relative "sources/json"
6
+ require_relative "sources/api"
7
+
8
+ module DataPorter
9
+ module Sources
10
+ REGISTRY = {
11
+ api: Api,
12
+ csv: Csv,
13
+ json: Json
14
+ }.freeze
15
+
16
+ def self.resolve(type)
17
+ REGISTRY.fetch(type.to_sym) { raise Error, "Unknown source type: #{type}" }
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "store_model"
4
+
5
+ module DataPorter
6
+ module StoreModels
7
+ class Error
8
+ include StoreModel::Model
9
+
10
+ attribute :message, :string
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "store_model"
4
+ require_relative "error"
5
+
6
+ module DataPorter
7
+ module StoreModels
8
+ class ImportRecord
9
+ include StoreModel::Model
10
+
11
+ attribute :line_number, :integer
12
+ attribute :status, :string, default: "pending"
13
+ attribute :data, default: -> { {} }
14
+ attribute :errors_list, Error.to_array_type, default: -> { [] }
15
+ attribute :warnings, Error.to_array_type, default: -> { [] }
16
+ attribute :target_id, :integer
17
+ attribute :dry_run_passed, :boolean, default: false
18
+
19
+ def complete? = status == "complete"
20
+
21
+ def importable? = status == "complete"
22
+
23
+ def add_error(message)
24
+ errors_list << Error.new(message: message)
25
+ end
26
+
27
+ def add_warning(message)
28
+ warnings << Error.new(message: message)
29
+ end
30
+
31
+ def attributes
32
+ data.symbolize_keys.compact
33
+ end
34
+
35
+ def determine_status!
36
+ self.status = if required_error?
37
+ "missing"
38
+ elsif errors_list.any?
39
+ "partial"
40
+ else
41
+ "complete"
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def required_error?
48
+ errors_list.any? { |e| e.message.include?("required") }
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "store_model"
4
+ require_relative "error"
5
+
6
+ module DataPorter
7
+ module StoreModels
8
+ class Report
9
+ include StoreModel::Model
10
+
11
+ attribute :records_count, :integer, default: 0
12
+ attribute :complete_count, :integer, default: 0
13
+ attribute :partial_count, :integer, default: 0
14
+ attribute :missing_count, :integer, default: 0
15
+ attribute :duplicate_count, :integer, default: 0
16
+ attribute :imported_count, :integer, default: 0
17
+ attribute :errored_count, :integer, default: 0
18
+ attribute :error_reports, Error.to_array_type, default: []
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dsl/column"
4
+ require_relative "dsl/api_config"
5
+
6
+ module DataPorter
7
+ class Target
8
+ class << self
9
+ attr_reader :_label, :_model_name, :_icon, :_sources,
10
+ :_columns, :_csv_mappings, :_dedup_keys, :_json_root,
11
+ :_api_config, :_dry_run_enabled
12
+
13
+ def label(value)
14
+ @_label = value
15
+ auto_register
16
+ end
17
+
18
+ def model_name(value)
19
+ @_model_name = value
20
+ end
21
+
22
+ def icon(value)
23
+ @_icon = value
24
+ end
25
+
26
+ def sources(*types)
27
+ @_sources = types.map(&:to_sym)
28
+ end
29
+
30
+ def columns(&)
31
+ @_columns = []
32
+ instance_eval(&)
33
+ end
34
+
35
+ def column(name, **)
36
+ @_columns << DSL::Column.new(name: name, **)
37
+ end
38
+
39
+ def csv_mapping(&)
40
+ @_csv_mappings = {}
41
+ instance_eval(&)
42
+ end
43
+
44
+ def map(hash)
45
+ @_csv_mappings.merge!(hash)
46
+ end
47
+
48
+ def deduplicate_by(*keys)
49
+ @_dedup_keys = keys.map(&:to_sym)
50
+ end
51
+
52
+ def json_root(path)
53
+ @_json_root = path
54
+ end
55
+
56
+ def api_config(&)
57
+ @_api_config = DSL::ApiConfig.new
58
+ @_api_config.instance_eval(&)
59
+ end
60
+
61
+ def dry_run_enabled
62
+ @_dry_run_enabled = true
63
+ end
64
+
65
+ private
66
+
67
+ def auto_register
68
+ return unless name
69
+
70
+ key = name.demodulize.delete_suffix("Target").underscore
71
+ Registry.register(key, self)
72
+ end
73
+ end
74
+
75
+ def transform(record)
76
+ record
77
+ end
78
+
79
+ def validate(record); end
80
+
81
+ def persist(_record, context:)
82
+ raise NotImplementedError
83
+ end
84
+
85
+ def after_import(_results, context:); end
86
+
87
+ def on_error(_record, _error, context:); end
88
+ end
89
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "date"
5
+
6
+ module DataPorter
7
+ module TypeValidator
8
+ VALIDATORS = {
9
+ string: ->(_value, _opts) { true },
10
+ integer: ->(value, _opts) { Integer(value, exception: false) },
11
+ decimal: ->(value, _opts) { Float(value, exception: false) },
12
+ date: ->(value, opts) { parse_date(value, opts) },
13
+ datetime: ->(value, _opts) { DateTime.parse(value) rescue false }, # rubocop:disable Style/RescueModifier
14
+ email: ->(value, _opts) { value.match?(/\A[^@\s]+@[^@\s]+\z/) },
15
+ phone: ->(value, _opts) { value.match?(/\A[+\d][\d\s\-().]{6,}\z/) },
16
+ url: ->(value, _opts) { valid_url?(value) },
17
+ boolean: ->(value, _opts) { %w[true false 1 0].include?(value.to_s.downcase) }
18
+ }.freeze
19
+
20
+ def self.valid?(value, type, options = {})
21
+ validator = VALIDATORS[type.to_sym]
22
+ return true unless validator
23
+
24
+ !!validator.call(value.to_s, options)
25
+ end
26
+
27
+ def self.parse_date(value, opts)
28
+ if opts[:format]
29
+ Date.strptime(value.to_s, opts[:format])
30
+ else
31
+ Date.parse(value.to_s)
32
+ end
33
+ rescue ArgumentError
34
+ false
35
+ end
36
+
37
+ def self.valid_url?(value)
38
+ uri = URI.parse(value.to_s)
39
+ uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
40
+ rescue URI::InvalidURIError
41
+ false
42
+ end
43
+
44
+ private_class_method :parse_date, :valid_url?
45
+ end
46
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module DataPorter
6
+ class Error < StandardError; end
7
+ end
8
+
9
+ require_relative "data_porter/version"
10
+ require_relative "data_porter/configuration"
11
+ require_relative "data_porter/type_validator"
12
+ require_relative "data_porter/store_models/error"
13
+ require_relative "data_porter/store_models/report"
14
+ require_relative "data_porter/store_models/import_record"
15
+ require_relative "data_porter/target"
16
+ require_relative "data_porter/registry"
17
+ require_relative "data_porter/sources"
18
+ require_relative "data_porter/record_validator"
19
+ require_relative "data_porter/broadcaster"
20
+ require_relative "data_porter/orchestrator"
21
+ require_relative "data_porter/components"
22
+ require_relative "data_porter/engine"
23
+
24
+ module DataPorter
25
+ def self.configuration
26
+ @configuration ||= Configuration.new
27
+ end
28
+
29
+ def self.configure
30
+ yield(configuration)
31
+ end
32
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module DataPorter
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ def copy_migration
14
+ migration_template(
15
+ "create_data_porter_imports.rb.erb",
16
+ "db/migrate/create_data_porter_imports.rb"
17
+ )
18
+ end
19
+
20
+ def copy_initializer
21
+ template("initializer.rb", "config/initializers/data_porter.rb")
22
+ end
23
+
24
+ def create_importers_directory
25
+ empty_directory("app/importers")
26
+ end
27
+
28
+ def mount_engine
29
+ route 'mount DataPorter::Engine, at: "/imports"'
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateDataPorterImports < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ def change
5
+ create_table :data_porter_imports do |t|
6
+ t.string :target_key, null: false
7
+ t.string :source_type, null: false, default: "csv"
8
+ t.integer :status, null: false, default: 0
9
+ t.jsonb :records, null: false, default: []
10
+ t.jsonb :report, null: false, default: {}
11
+ t.jsonb :config, null: false, default: {}
12
+
13
+ t.references :user, polymorphic: true, null: false
14
+
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :data_porter_imports, :status
19
+ add_index :data_porter_imports, :target_key
20
+ end
21
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ DataPorter.configure do |config|
4
+ # Parent controller for the engine's controllers to inherit from.
5
+ # This controls authentication, layouts, and helpers.
6
+ # config.parent_controller = "ApplicationController"
7
+
8
+ # ActiveJob queue name for import jobs.
9
+ # config.queue_name = :imports
10
+
11
+ # ActiveStorage service for uploaded files.
12
+ # config.storage_service = :local
13
+
14
+ # ActionCable channel prefix.
15
+ # config.cable_channel_prefix = "data_porter"
16
+
17
+ # Context builder: inject business data into targets.
18
+ # Receives the current controller instance.
19
+ # config.context_builder = ->(controller) {
20
+ # OpenStruct.new(
21
+ # user: controller.current_user
22
+ # )
23
+ # }
24
+
25
+ # Maximum number of records displayed in preview.
26
+ # config.preview_limit = 500
27
+
28
+ # Enabled source types.
29
+ # config.enabled_sources = %i[csv json api]
30
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module DataPorter
6
+ module Generators
7
+ class TargetGenerator < Rails::Generators::NamedBase
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ argument :columns, type: :array, default: [], banner: "name:type[:required]"
11
+
12
+ def create_target_file
13
+ template("target.rb.tt", "app/importers/#{file_name}_target.rb")
14
+ end
15
+
16
+ private
17
+
18
+ def target_class_name
19
+ "#{class_name}Target"
20
+ end
21
+
22
+ def model_name
23
+ class_name.singularize
24
+ end
25
+
26
+ def target_label
27
+ class_name.titleize
28
+ end
29
+
30
+ def parsed_columns
31
+ columns.map { |col| parse_column(col) }
32
+ end
33
+
34
+ def parse_column(definition)
35
+ parts = definition.split(":")
36
+ {
37
+ name: parts[0],
38
+ type: parts[1] || "string",
39
+ required: parts[2] == "required"
40
+ }
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= target_class_name %> < DataPorter::Target
4
+ label "<%= target_label %>"
5
+ model_name "<%= model_name %>"
6
+ icon "fas fa-file-import"
7
+ sources :csv
8
+ <% if parsed_columns.any? %>
9
+
10
+ columns do
11
+ <% parsed_columns.each do |col| -%>
12
+ column :<%= col[:name] %>, type: :<%= col[:type] %><%= ", required: true" if col[:required] %>
13
+ <% end -%>
14
+ end
15
+ <% end %>
16
+
17
+ def persist(record, context:)
18
+ # <%= model_name %>.create!(record.attributes)
19
+ end
20
+ end
@@ -0,0 +1,4 @@
1
+ module DataPorter
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end