railspress-engine 1.2.0 → 1.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/app/assets/javascripts/railspress/admin.js +54 -0
  4. data/app/assets/stylesheets/railspress/admin/buttons.css +12 -0
  5. data/app/assets/stylesheets/railspress/admin/cards.css +8 -0
  6. data/app/assets/stylesheets/railspress/admin/forms.css +21 -0
  7. data/app/assets/stylesheets/railspress/admin/layout.css +88 -0
  8. data/app/assets/stylesheets/railspress/admin/responsive.css +15 -0
  9. data/app/controllers/railspress/admin/agent_bootstrap_keys_controller.rb +109 -0
  10. data/app/controllers/railspress/admin/api_keys_controller.rb +165 -0
  11. data/app/controllers/railspress/admin/base_controller.rb +61 -1
  12. data/app/controllers/railspress/api/v1/agent_key_exchanges_controller.rb +50 -0
  13. data/app/controllers/railspress/api/v1/base_controller.rb +52 -0
  14. data/app/controllers/railspress/api/v1/categories_controller.rb +89 -0
  15. data/app/controllers/railspress/api/v1/concerns/post_serialization.rb +130 -0
  16. data/app/controllers/railspress/api/v1/post_header_image_contexts_controller.rb +158 -0
  17. data/app/controllers/railspress/api/v1/post_header_image_focal_points_controller.rb +74 -0
  18. data/app/controllers/railspress/api/v1/post_header_images_controller.rb +58 -0
  19. data/app/controllers/railspress/api/v1/post_imports_controller.rb +118 -0
  20. data/app/controllers/railspress/api/v1/posts_controller.rb +127 -0
  21. data/app/controllers/railspress/api/v1/prime_controller.rb +78 -0
  22. data/app/controllers/railspress/api/v1/tags_controller.rb +85 -0
  23. data/app/helpers/railspress/admin_helper.rb +19 -0
  24. data/app/models/railspress/agent_bootstrap_key.rb +163 -0
  25. data/app/models/railspress/api_key.rb +157 -0
  26. data/app/models/railspress/post_export_processor.rb +16 -2
  27. data/app/views/railspress/admin/agent_bootstrap_keys/_form.html.erb +25 -0
  28. data/app/views/railspress/admin/agent_bootstrap_keys/new.html.erb +7 -0
  29. data/app/views/railspress/admin/agent_bootstrap_keys/reveal.html.erb +38 -0
  30. data/app/views/railspress/admin/api_keys/_form.html.erb +25 -0
  31. data/app/views/railspress/admin/api_keys/index.html.erb +142 -0
  32. data/app/views/railspress/admin/api_keys/new.html.erb +7 -0
  33. data/app/views/railspress/admin/api_keys/reveal.html.erb +40 -0
  34. data/app/views/railspress/admin/posts/_form.html.erb +1 -1
  35. data/app/views/railspress/admin/posts/_post_row.html.erb +1 -1
  36. data/app/views/railspress/admin/posts/show.html.erb +1 -1
  37. data/app/views/railspress/admin/shared/_copyable_textarea.html.erb +17 -0
  38. data/app/views/railspress/admin/shared/_sidebar.html.erb +46 -0
  39. data/config/routes.rb +33 -0
  40. data/db/migrate/20260415000001_create_railspress_api_keys.rb +40 -0
  41. data/db/migrate/20260415000002_create_railspress_agent_bootstrap_keys.rb +37 -0
  42. data/lib/generators/railspress/install/templates/initializer.rb +11 -0
  43. data/lib/railspress/version.rb +1 -1
  44. data/lib/railspress.rb +49 -1
  45. metadata +26 -1
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Api
5
+ module V1
6
+ class BaseController < ActionController::API
7
+ include ActionController::HttpAuthentication::Token::ControllerMethods
8
+
9
+ before_action :ensure_api_enabled!
10
+ before_action :authenticate_api_key!
11
+
12
+ rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
13
+
14
+ attr_reader :current_api_key
15
+
16
+ private
17
+
18
+ def ensure_api_enabled!
19
+ render_error("API is not enabled.", status: :not_found) unless Railspress.api_enabled?
20
+ end
21
+
22
+ def authenticate_api_key!
23
+ return if authenticate_with_http_token { |token, _opts| authenticate_with_token(token) }
24
+
25
+ render_error("Unauthorized", status: :unauthorized)
26
+ end
27
+
28
+ def authenticate_with_token(token)
29
+ @current_api_key = Railspress::ApiKey.authenticate(token, ip_address: request.remote_ip)
30
+ @current_api_key.present?
31
+ end
32
+
33
+ def render_not_found
34
+ render_error("Resource not found.", status: :not_found)
35
+ end
36
+
37
+ def render_validation_errors(record)
38
+ render json: {
39
+ error: {
40
+ message: "Validation failed.",
41
+ details: record.errors.full_messages
42
+ }
43
+ }, status: :unprocessable_content
44
+ end
45
+
46
+ def render_error(message, status:)
47
+ render json: { error: { message: message } }, status: status
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Api
5
+ module V1
6
+ class CategoriesController < BaseController
7
+ before_action :set_category, only: [ :show, :update, :destroy ]
8
+
9
+ def index
10
+ categories = Railspress::Category.ordered
11
+ total_count = categories.count
12
+ categories = categories.offset((page - 1) * per_page).limit(per_page)
13
+
14
+ render json: {
15
+ data: categories.map { |category| serialize_category(category) },
16
+ meta: {
17
+ page: page,
18
+ per: per_page,
19
+ total_count: total_count,
20
+ total_pages: (total_count.to_f / per_page).ceil
21
+ }
22
+ }
23
+ end
24
+
25
+ def show
26
+ render json: { data: serialize_category(@category) }
27
+ end
28
+
29
+ def create
30
+ category = Railspress::Category.new(category_params)
31
+
32
+ if category.save
33
+ render json: { data: serialize_category(category) }, status: :created
34
+ else
35
+ render_validation_errors(category)
36
+ end
37
+ end
38
+
39
+ def update
40
+ if @category.update(category_params)
41
+ render json: { data: serialize_category(@category) }
42
+ else
43
+ render_validation_errors(@category)
44
+ end
45
+ end
46
+
47
+ def destroy
48
+ if @category.destroy
49
+ head :no_content
50
+ else
51
+ render_validation_errors(@category)
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def set_category
58
+ @category = Railspress::Category.find(params[:id])
59
+ end
60
+
61
+ def category_params
62
+ params.require(:category).permit(:name, :slug, :description)
63
+ end
64
+
65
+ def page
66
+ [ params.fetch(:page, 1).to_i, 1 ].max
67
+ end
68
+
69
+ def per_page
70
+ requested = params.fetch(:per, 20).to_i
71
+ requested = 20 if requested <= 0
72
+ [ requested, 100 ].min
73
+ end
74
+
75
+ def serialize_category(category)
76
+ {
77
+ id: category.id,
78
+ name: category.name,
79
+ slug: category.slug,
80
+ description: category.description,
81
+ posts_count: category.posts.count,
82
+ created_at: category.created_at,
83
+ updated_at: category.updated_at
84
+ }
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Api
5
+ module V1
6
+ module Concerns
7
+ module PostSerialization
8
+ private
9
+
10
+ def serialize_post(post)
11
+ {
12
+ id: post.id,
13
+ title: post.title,
14
+ slug: post.slug,
15
+ status: post.status,
16
+ published_at: post.published_at,
17
+ reading_time: post.reading_time_display,
18
+ meta_title: post.meta_title,
19
+ meta_description: post.meta_description,
20
+ category_id: post.category_id,
21
+ author_id: post.author_id,
22
+ author_display: serialize_author_display(post),
23
+ tag_list: post.tag_list,
24
+ content: post.content&.to_s,
25
+ header_image: serialize_header_image(post),
26
+ header_image_focal_point: serialize_header_image_focal_point(post),
27
+ created_at: post.created_at,
28
+ updated_at: post.updated_at
29
+ }
30
+ end
31
+
32
+ def serialize_header_image(post)
33
+ return nil unless Railspress.post_images_enabled?
34
+ return { attached: false } unless post.header_image.attached?
35
+
36
+ blob = post.header_image.blob
37
+
38
+ {
39
+ attached: true,
40
+ blob_id: blob.id,
41
+ signed_blob_id: blob.signed_id,
42
+ filename: blob.filename.to_s,
43
+ byte_size: blob.byte_size,
44
+ content_type: blob.content_type
45
+ }
46
+ end
47
+
48
+ def serialize_focal_point(focal_point)
49
+ {
50
+ id: focal_point.id,
51
+ attachment_name: focal_point.attachment_name,
52
+ focal_x: focal_point.focal_x.to_f,
53
+ focal_y: focal_point.focal_y.to_f,
54
+ overrides: focal_point.overrides || {}
55
+ }
56
+ end
57
+
58
+ def serialize_header_image_focal_point(post)
59
+ return nil unless Railspress.post_images_enabled? && Railspress.focal_points_enabled?
60
+ return nil unless post.header_image.attached?
61
+
62
+ focal_point = post.header_image_focal_point
63
+ focal_point.save! if focal_point.new_record?
64
+ serialize_focal_point(focal_point)
65
+ end
66
+
67
+ def serialize_header_image_context(post, context_name, context_config: nil)
68
+ context_name = context_name.to_s
69
+ context_config ||= Railspress.image_contexts[context_name.to_sym]
70
+ override = post.image_override(context_name, :header_image)
71
+
72
+ {
73
+ name: context_name,
74
+ label: context_config&.dig(:label) || context_name.humanize,
75
+ aspect: context_config&.dig(:aspect),
76
+ sizes: context_config&.dig(:sizes) || [],
77
+ has_override: post.has_image_override?(context_name, :header_image),
78
+ override: serialize_context_override(override),
79
+ image_css: post.image_css_for(context_name, :header_image),
80
+ image: serialize_context_image(post, context_name, override)
81
+ }
82
+ end
83
+
84
+ def serialize_author_display(post)
85
+ return nil unless Railspress.authors_enabled?
86
+ return nil unless post.author.present?
87
+
88
+ configured_method = Railspress.author_display_method
89
+ if configured_method.present? && post.author.respond_to?(configured_method)
90
+ configured_value = post.author.public_send(configured_method)
91
+ return configured_value if configured_value.present?
92
+ end
93
+
94
+ fallback_method = [ :name, :full_name, :display_name, :email, :email_address ]
95
+ .find { |method| post.author.respond_to?(method) && post.author.public_send(method).present? }
96
+
97
+ fallback_method ? post.author.public_send(fallback_method) : "Author ##{post.author.id || "unknown"}"
98
+ end
99
+
100
+ def serialize_context_override(override)
101
+ return { type: "focal" } if override.blank?
102
+
103
+ override.to_h.deep_stringify_keys
104
+ end
105
+
106
+ def serialize_context_image(post, context_name, override)
107
+ image = post.image_for(context_name, :header_image)
108
+ blob = if image.is_a?(ActiveStorage::Blob)
109
+ image
110
+ elsif image.respond_to?(:blob)
111
+ image.blob
112
+ end
113
+ return nil unless blob.present?
114
+
115
+ override_type = override&.dig(:type) || override&.dig("type")
116
+
117
+ {
118
+ source: override_type == "upload" ? "upload_override" : "header_image",
119
+ blob_id: blob.id,
120
+ signed_blob_id: blob.signed_id,
121
+ filename: blob.filename.to_s,
122
+ byte_size: blob.byte_size,
123
+ content_type: blob.content_type
124
+ }
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Api
5
+ module V1
6
+ class PostHeaderImageContextsController < BaseController
7
+ include Railspress::Api::V1::Concerns::PostSerialization
8
+
9
+ rescue_from ActionController::BadRequest, with: :render_unprocessable_request
10
+
11
+ before_action :ensure_post_images_enabled!
12
+ before_action :ensure_focal_points_enabled!
13
+ before_action :set_post
14
+ before_action :ensure_header_image_attached!
15
+ before_action :set_context, only: [ :show, :update, :destroy ]
16
+
17
+ def index
18
+ render json: {
19
+ data: available_contexts.map do |context_name, context_config|
20
+ serialize_header_image_context(@post, context_name, context_config: context_config)
21
+ end
22
+ }
23
+ end
24
+
25
+ def show
26
+ render json: { data: serialize_header_image_context(@post, @context_name, context_config: @context_config) }
27
+ end
28
+
29
+ def update
30
+ apply_context_override!
31
+ return if performed?
32
+
33
+ render json: {
34
+ data: serialize_header_image_context(@post, @context_name, context_config: @context_config),
35
+ post: serialize_post(@post.reload)
36
+ }
37
+ rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
38
+ render_error("Invalid signed blob id.", status: :unprocessable_content)
39
+ end
40
+
41
+ def destroy
42
+ @post.clear_image_override(@context_name, :header_image)
43
+ @post.save!
44
+
45
+ render json: {
46
+ data: serialize_header_image_context(@post, @context_name, context_config: @context_config),
47
+ post: serialize_post(@post.reload)
48
+ }
49
+ end
50
+
51
+ private
52
+
53
+ def ensure_post_images_enabled!
54
+ render_error("Post images are not enabled.", status: :not_found) unless Railspress.post_images_enabled?
55
+ end
56
+
57
+ def ensure_focal_points_enabled!
58
+ render_error("Focal points are not enabled.", status: :not_found) unless Railspress.focal_points_enabled?
59
+ end
60
+
61
+ def set_post
62
+ @post = Railspress::Post.find(params[:post_id])
63
+ end
64
+
65
+ def ensure_header_image_attached!
66
+ render_error("Header image is not attached.", status: :unprocessable_content) unless @post.header_image.attached?
67
+ end
68
+
69
+ def set_context
70
+ @context_name = params[:context].to_s
71
+ @context_config = available_contexts[@context_name.to_sym]
72
+ return if @context_config.present?
73
+
74
+ render_error("Image context not found.", status: :not_found)
75
+ end
76
+
77
+ def available_contexts
78
+ @available_contexts ||= Railspress.image_contexts || {}
79
+ end
80
+
81
+ def apply_context_override!
82
+ override = override_params
83
+
84
+ case override[:type]
85
+ when "focal"
86
+ @post.clear_image_override(@context_name, :header_image)
87
+ when "crop"
88
+ @post.set_image_override(@context_name, {
89
+ "type" => "crop",
90
+ "region" => normalize_crop_region(override[:region])
91
+ }, :header_image)
92
+ when "upload"
93
+ @post.set_image_override(@context_name, {
94
+ "type" => "upload",
95
+ "blob_signed_id" => signed_blob_id_for_upload(override)
96
+ }, :header_image)
97
+ else
98
+ return render_error("Override type must be one of: focal, crop, upload.", status: :unprocessable_content)
99
+ end
100
+
101
+ return if @post.save
102
+
103
+ render_validation_errors(@post.header_image_focal_point)
104
+ end
105
+
106
+ def override_params
107
+ params.require(:override).permit(:type, :signed_blob_id, :blob_signed_id, region: [ :x, :y, :width, :height ])
108
+ end
109
+
110
+ def normalize_crop_region(raw_region)
111
+ unless raw_region.present?
112
+ raise ActionController::BadRequest, "Crop overrides require a region hash."
113
+ end
114
+
115
+ region = raw_region.to_h.transform_values { |value| Float(value) }
116
+ validate_crop_region!(region)
117
+
118
+ {
119
+ "x" => region.fetch("x").to_f,
120
+ "y" => region.fetch("y").to_f,
121
+ "width" => region.fetch("width").to_f,
122
+ "height" => region.fetch("height").to_f
123
+ }
124
+ rescue KeyError
125
+ raise ActionController::BadRequest, "Crop region must include x, y, width, and height."
126
+ rescue ArgumentError
127
+ raise ActionController::BadRequest, "Crop region values must be numeric."
128
+ end
129
+
130
+ def validate_crop_region!(region)
131
+ values = [ region.fetch("x"), region.fetch("y"), region.fetch("width"), region.fetch("height") ]
132
+ if values.any? { |value| value.negative? || value > 1 }
133
+ raise ActionController::BadRequest, "Crop region values must stay within 0..1."
134
+ end
135
+
136
+ if region.fetch("width").zero? || region.fetch("height").zero?
137
+ raise ActionController::BadRequest, "Crop region width and height must be greater than 0."
138
+ end
139
+
140
+ if region.fetch("x") + region.fetch("width") > 1 || region.fetch("y") + region.fetch("height") > 1
141
+ raise ActionController::BadRequest, "Crop region must fit within the source image bounds."
142
+ end
143
+ end
144
+
145
+ def signed_blob_id_for_upload(override)
146
+ signed_blob_id = override[:signed_blob_id].presence || override[:blob_signed_id].presence
147
+ raise ActionController::BadRequest, "Upload overrides require signed_blob_id." if signed_blob_id.blank?
148
+
149
+ ActiveStorage::Blob.find_signed!(signed_blob_id).signed_id
150
+ end
151
+
152
+ def render_unprocessable_request(error)
153
+ render_error(error.message, status: :unprocessable_content)
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Api
5
+ module V1
6
+ class PostHeaderImageFocalPointsController < BaseController
7
+ include Railspress::Api::V1::Concerns::PostSerialization
8
+
9
+ before_action :ensure_post_images_enabled!
10
+ before_action :ensure_focal_points_enabled!
11
+ before_action :set_post
12
+ before_action :ensure_header_image_attached!
13
+
14
+ def show
15
+ render json: { data: serialize_focal_point(current_focal_point) }
16
+ end
17
+
18
+ def update
19
+ focal_point = current_focal_point
20
+
21
+ if focal_point.update(focal_point_params)
22
+ render json: {
23
+ data: serialize_focal_point(focal_point),
24
+ post: serialize_post(@post.reload)
25
+ }
26
+ else
27
+ render_validation_errors(focal_point)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def ensure_post_images_enabled!
34
+ render_error("Post images are not enabled.", status: :not_found) unless Railspress.post_images_enabled?
35
+ end
36
+
37
+ def ensure_focal_points_enabled!
38
+ render_error("Focal points are not enabled.", status: :not_found) unless Railspress.focal_points_enabled?
39
+ end
40
+
41
+ def set_post
42
+ @post = Railspress::Post.find(params[:post_id])
43
+ end
44
+
45
+ def ensure_header_image_attached!
46
+ render_error("Header image is not attached.", status: :unprocessable_content) unless @post.header_image.attached?
47
+ end
48
+
49
+ def current_focal_point
50
+ focal_point = @post.header_image_focal_point
51
+ focal_point.save! if focal_point.new_record?
52
+ focal_point
53
+ end
54
+
55
+ def focal_point_params
56
+ permitted = params.require(:focal_point).permit(:focal_x, :focal_y, overrides: {})
57
+ permitted[:overrides] = normalize_overrides(permitted[:overrides])
58
+ permitted
59
+ end
60
+
61
+ def normalize_overrides(overrides)
62
+ return {} if overrides.blank?
63
+ return overrides.to_unsafe_h if overrides.is_a?(ActionController::Parameters)
64
+ return overrides if overrides.is_a?(Hash)
65
+ return JSON.parse(overrides) if overrides.is_a?(String)
66
+
67
+ {}
68
+ rescue JSON::ParserError
69
+ {}
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Api
5
+ module V1
6
+ class PostHeaderImagesController < BaseController
7
+ include Railspress::Api::V1::Concerns::PostSerialization
8
+
9
+ before_action :ensure_post_images_enabled!
10
+ before_action :set_post
11
+
12
+ def show
13
+ return render_error("Header image not found.", status: :not_found) unless @post.header_image.attached?
14
+
15
+ render json: {
16
+ data: {
17
+ post_id: @post.id,
18
+ header_image: serialize_header_image(@post),
19
+ header_image_focal_point: serialize_header_image_focal_point(@post)
20
+ }
21
+ }
22
+ end
23
+
24
+ def update
25
+ image = header_image_param
26
+ return render_error("Either image or signed_blob_id is required.", status: :unprocessable_content) if image.blank?
27
+
28
+ @post.header_image.attach(image)
29
+ render json: { data: serialize_post(@post.reload) }
30
+ rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
31
+ render_error("Invalid signed blob id.", status: :unprocessable_content)
32
+ end
33
+
34
+ def destroy
35
+ @post.header_image.purge if @post.header_image.attached?
36
+ head :no_content
37
+ end
38
+
39
+ private
40
+
41
+ def ensure_post_images_enabled!
42
+ render_error("Post images are not enabled.", status: :not_found) unless Railspress.post_images_enabled?
43
+ end
44
+
45
+ def set_post
46
+ @post = Railspress::Post.find(params[:post_id])
47
+ end
48
+
49
+ def header_image_param
50
+ return params[:image] if params[:image].present?
51
+ return if params[:signed_blob_id].blank?
52
+
53
+ ActiveStorage::Blob.find_signed!(params[:signed_blob_id])
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Api
5
+ module V1
6
+ class PostImportsController < BaseController
7
+ SUPPORTED_EXTENSIONS = %w[.md .markdown .txt .zip].freeze
8
+
9
+ before_action :set_import, only: [ :show ]
10
+
11
+ def create
12
+ source = import_source
13
+ return render_error("Either file or signed_blob_id is required.", status: :unprocessable_content) unless source
14
+
15
+ unless supported_file?(source[:filename])
16
+ return render_error("Unsupported import file type. Allowed: .md, .markdown, .txt, .zip.", status: :unprocessable_content)
17
+ end
18
+
19
+ import = Railspress::Import.create!(
20
+ import_type: "posts",
21
+ filename: source[:filename],
22
+ content_type: source[:content_type],
23
+ status: "pending",
24
+ user_id: current_api_key&.owner_id
25
+ )
26
+
27
+ file_path = persist_import_file(import, source)
28
+ Railspress::ImportPostsJob.perform_later(import.id, [ file_path ])
29
+
30
+ render json: { data: serialize_import(import.reload) }, status: :accepted
31
+ rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
32
+ render_error("Invalid signed blob id.", status: :unprocessable_content)
33
+ rescue => error
34
+ Rails.logger.warn("Failed to create post import via API: #{error.class} #{error.message}")
35
+ render_error("Failed to queue import.", status: :unprocessable_content)
36
+ end
37
+
38
+ def show
39
+ render json: { data: serialize_import(@import) }
40
+ end
41
+
42
+ private
43
+
44
+ def set_import
45
+ @import = Railspress::Import.where(import_type: "posts").find(params[:id])
46
+ end
47
+
48
+ def import_source
49
+ upload = params[:file] || params.dig(:import, :file)
50
+ return upload_source(upload) if upload.present?
51
+
52
+ signed_blob_id = params[:signed_blob_id] || params.dig(:import, :signed_blob_id)
53
+ return nil if signed_blob_id.blank?
54
+
55
+ blob_source(ActiveStorage::Blob.find_signed!(signed_blob_id))
56
+ end
57
+
58
+ def upload_source(upload)
59
+ {
60
+ type: :upload,
61
+ value: upload,
62
+ filename: upload.original_filename,
63
+ content_type: upload.content_type
64
+ }
65
+ end
66
+
67
+ def blob_source(blob)
68
+ {
69
+ type: :blob,
70
+ value: blob,
71
+ filename: blob.filename.to_s,
72
+ content_type: blob.content_type
73
+ }
74
+ end
75
+
76
+ def supported_file?(filename)
77
+ extension = File.extname(filename.to_s).downcase
78
+ SUPPORTED_EXTENSIONS.include?(extension)
79
+ end
80
+
81
+ def persist_import_file(import, source)
82
+ upload_dir = Rails.root.join("tmp", "uploads", "import_#{import.id}")
83
+ FileUtils.mkdir_p(upload_dir)
84
+
85
+ filename = File.basename(source[:filename].to_s)
86
+ destination = upload_dir.join(filename)
87
+
88
+ case source[:type]
89
+ when :upload
90
+ FileUtils.cp(source[:value].path, destination)
91
+ when :blob
92
+ source[:value].open do |file|
93
+ FileUtils.cp(file.path, destination)
94
+ end
95
+ end
96
+
97
+ destination.to_s
98
+ end
99
+
100
+ def serialize_import(import)
101
+ {
102
+ id: import.id,
103
+ import_type: import.import_type,
104
+ filename: import.filename,
105
+ content_type: import.content_type,
106
+ status: import.status,
107
+ total_count: import.total_count,
108
+ success_count: import.success_count,
109
+ error_count: import.error_count,
110
+ error_messages: import.parsed_errors,
111
+ created_at: import.created_at,
112
+ updated_at: import.updated_at
113
+ }
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end