railspress-engine 0.1.2 → 1.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.
- checksums.yaml +4 -4
- data/LICENSE +20 -0
- data/README.md +195 -25
- data/app/assets/javascripts/railspress/admin.js +39 -0
- data/app/assets/javascripts/railspress/markdown_mode.js +343 -0
- data/app/assets/stylesheets/application.css +0 -0
- data/app/assets/stylesheets/railspress/admin/badges.css +70 -0
- data/app/assets/stylesheets/railspress/admin/base.css +25 -0
- data/app/assets/stylesheets/railspress/admin/buttons.css +140 -0
- data/app/assets/stylesheets/railspress/admin/cards.css +52 -0
- data/app/assets/stylesheets/railspress/admin/components/exports.css +55 -0
- data/app/assets/stylesheets/railspress/admin/components/focal_point.css +801 -0
- data/app/assets/stylesheets/railspress/admin/components/imports.css +144 -0
- data/app/assets/stylesheets/railspress/admin/components/lexxy.css +156 -0
- data/app/assets/stylesheets/railspress/admin/filters.css +73 -0
- data/app/assets/stylesheets/railspress/admin/flash.css +26 -0
- data/app/assets/stylesheets/railspress/admin/forms.css +459 -0
- data/app/assets/stylesheets/railspress/admin/layout.css +256 -0
- data/app/assets/stylesheets/railspress/admin/lists.css +24 -0
- data/app/assets/stylesheets/railspress/admin/page.css +111 -0
- data/app/assets/stylesheets/railspress/admin/responsive.css +174 -0
- data/app/assets/stylesheets/railspress/admin/stats.css +43 -0
- data/app/assets/stylesheets/railspress/admin/tables.css +163 -0
- data/app/assets/stylesheets/railspress/admin/utilities.css +202 -0
- data/app/assets/stylesheets/railspress/admin/variables.css +58 -0
- data/app/assets/stylesheets/railspress/application.css +44 -13
- data/app/controllers/railspress/admin/base_controller.rb +6 -3
- data/app/controllers/railspress/admin/categories_controller.rb +1 -1
- data/app/controllers/railspress/admin/cms_transfers_controller.rb +49 -0
- data/app/controllers/railspress/admin/content_element_versions_controller.rb +12 -0
- data/app/controllers/railspress/admin/content_elements_controller.rb +143 -0
- data/app/controllers/railspress/admin/content_groups_controller.rb +69 -0
- data/app/controllers/railspress/admin/dashboard_controller.rb +6 -0
- data/app/controllers/railspress/admin/entities_controller.rb +157 -0
- data/app/controllers/railspress/admin/exports_controller.rb +55 -0
- data/app/controllers/railspress/admin/focal_points_controller.rb +100 -0
- data/app/controllers/railspress/admin/imports_controller.rb +63 -0
- data/app/controllers/railspress/admin/posts_controller.rb +58 -4
- data/app/controllers/railspress/admin/prototypes_controller.rb +30 -0
- data/app/controllers/railspress/admin/tags_controller.rb +1 -1
- data/app/controllers/railspress/application_controller.rb +1 -0
- data/app/helpers/railspress/admin_helper.rb +733 -0
- data/app/helpers/railspress/application_helper.rb +23 -0
- data/app/helpers/railspress/cms_helper.rb +319 -0
- data/app/javascript/railspress/controllers/cms_inline_editor_controller.js +147 -0
- data/app/javascript/railspress/controllers/content_element_form_controller.js +15 -0
- data/app/javascript/railspress/controllers/crop_controller.js +224 -0
- data/app/javascript/railspress/controllers/dropzone_controller.js +261 -0
- data/app/javascript/railspress/controllers/focal_point_controller.js +124 -0
- data/app/javascript/railspress/controllers/image_section_controller.js +94 -0
- data/app/javascript/railspress/controllers/index.js +37 -0
- data/app/javascript/railspress/index.js +62 -0
- data/app/jobs/railspress/export_posts_job.rb +16 -0
- data/app/jobs/railspress/import_posts_job.rb +44 -0
- data/app/models/concerns/railspress/has_focal_point.rb +242 -0
- data/app/models/concerns/railspress/soft_deletable.rb +23 -0
- data/app/models/concerns/railspress/taggable.rb +23 -0
- data/app/models/railspress/content_element.rb +103 -0
- data/app/models/railspress/content_element_version.rb +32 -0
- data/app/models/railspress/content_group.rb +39 -0
- data/app/models/railspress/export.rb +67 -0
- data/app/models/railspress/focal_point.rb +70 -0
- data/app/models/railspress/import.rb +65 -0
- data/app/models/railspress/post.rb +102 -15
- data/app/models/railspress/post_export_processor.rb +162 -0
- data/app/models/railspress/post_import_processor.rb +382 -0
- data/app/models/railspress/tag.rb +10 -3
- data/app/models/railspress/tagging.rb +11 -0
- data/app/services/railspress/content_export_service.rb +122 -0
- data/app/services/railspress/content_import_service.rb +228 -0
- data/app/views/action_text/attachables/_remote_image.html.erb +8 -0
- data/app/views/active_storage/blobs/_blob.html.erb +1 -1
- data/app/views/layouts/railspress/admin.html.erb +3 -1
- data/app/views/railspress/admin/categories/index.html.erb +11 -15
- data/app/views/railspress/admin/cms_transfers/show.html.erb +167 -0
- data/app/views/railspress/admin/content_element_versions/show.html.erb +42 -0
- data/app/views/railspress/admin/content_elements/_form.html.erb +71 -0
- data/app/views/railspress/admin/content_elements/_inline_form.html.erb +32 -0
- data/app/views/railspress/admin/content_elements/_inline_form_frame.html.erb +6 -0
- data/app/views/railspress/admin/content_elements/edit.html.erb +6 -0
- data/app/views/railspress/admin/content_elements/index.html.erb +74 -0
- data/app/views/railspress/admin/content_elements/new.html.erb +6 -0
- data/app/views/railspress/admin/content_elements/show.html.erb +124 -0
- data/app/views/railspress/admin/content_groups/_form.html.erb +9 -0
- data/app/views/railspress/admin/content_groups/edit.html.erb +6 -0
- data/app/views/railspress/admin/content_groups/index.html.erb +42 -0
- data/app/views/railspress/admin/content_groups/new.html.erb +6 -0
- data/app/views/railspress/admin/content_groups/show.html.erb +92 -0
- data/app/views/railspress/admin/dashboard/index.html.erb +36 -1
- data/app/views/railspress/admin/entities/_form.html.erb +53 -0
- data/app/views/railspress/admin/entities/edit.html.erb +4 -0
- data/app/views/railspress/admin/entities/index.html.erb +74 -0
- data/app/views/railspress/admin/entities/new.html.erb +4 -0
- data/app/views/railspress/admin/entities/show.html.erb +117 -0
- data/app/views/railspress/admin/exports/show.html.erb +62 -0
- data/app/views/railspress/admin/imports/_instructions.html.erb +56 -0
- data/app/views/railspress/admin/imports/show.html.erb +137 -0
- data/app/views/railspress/admin/posts/_form.html.erb +102 -28
- data/app/views/railspress/admin/posts/_post_row.html.erb +40 -0
- data/app/views/railspress/admin/posts/index.html.erb +47 -36
- data/app/views/railspress/admin/posts/show.html.erb +55 -19
- data/app/views/railspress/admin/prototypes/image_section.html.erb +42 -0
- data/app/views/railspress/admin/shared/_dropzone.html.erb +84 -0
- data/app/views/railspress/admin/shared/_focal_point_editor.html.erb +102 -0
- data/app/views/railspress/admin/shared/_image_section.html.erb +159 -0
- data/app/views/railspress/admin/shared/_image_section_compact.html.erb +90 -0
- data/app/views/railspress/admin/shared/_image_section_editor.html.erb +171 -0
- data/app/views/railspress/admin/shared/_image_section_v2.html.erb +205 -0
- data/app/views/railspress/admin/shared/_sidebar.html.erb +73 -5
- data/app/views/railspress/admin/tags/index.html.erb +12 -16
- data/config/brakeman.ignore +18 -0
- data/config/importmap.rb +23 -0
- data/config/routes.rb +62 -1
- data/db/migrate/20241218000004_create_railspress_post_tags.rb +1 -1
- data/db/migrate/20241218000005_create_railspress_imports.rb +21 -0
- data/db/migrate/20241218000006_create_railspress_exports.rb +20 -0
- data/db/migrate/20241218000007_create_railspress_taggings.rb +20 -0
- data/db/migrate/20241218000008_drop_railspress_post_tags.rb +14 -0
- data/db/migrate/20241218000010_add_reading_time_to_railspress_posts.rb +5 -0
- data/db/migrate/20250105000002_create_railspress_focal_points.rb +20 -0
- data/db/migrate/20260206000001_create_railspress_content_groups.rb +18 -0
- data/db/migrate/20260206000002_create_railspress_content_elements.rb +21 -0
- data/db/migrate/20260206000003_create_railspress_content_element_versions.rb +20 -0
- data/db/migrate/20260207000001_add_unique_index_to_content_elements.rb +11 -0
- data/db/migrate/20260211112812_add_image_hint_to_railspress_content_elements.rb +7 -0
- data/db/migrate/20260211154040_add_required_to_railspress_content_elements.rb +5 -0
- data/lib/generators/railspress/entity/entity_generator.rb +89 -0
- data/lib/generators/railspress/entity/templates/migration.rb.tt +13 -0
- data/lib/generators/railspress/entity/templates/model.rb.tt +21 -0
- data/lib/generators/railspress/install/install_generator.rb +51 -40
- data/lib/generators/railspress/install/templates/initializer.rb +29 -0
- data/lib/railspress/engine.rb +38 -0
- data/lib/railspress/entity.rb +239 -0
- data/lib/railspress/version.rb +1 -1
- data/lib/railspress.rb +198 -8
- data/lib/tasks/railspress_tasks.rake +49 -4
- metadata +215 -21
- data/MIT-LICENSE +0 -20
- data/app/assets/stylesheets/railspress/admin.css +0 -1207
- data/app/models/railspress/post_tag.rb +0 -8
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railspress
|
|
4
|
+
class FocalPoint < ApplicationRecord
|
|
5
|
+
belongs_to :record, polymorphic: true
|
|
6
|
+
|
|
7
|
+
validates :attachment_name, presence: true
|
|
8
|
+
validates :focal_x, :focal_y,
|
|
9
|
+
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1 }
|
|
10
|
+
|
|
11
|
+
validate :validate_overrides_structure
|
|
12
|
+
|
|
13
|
+
# Valid override types
|
|
14
|
+
VALID_OVERRIDE_TYPES = %w[focal crop upload].freeze
|
|
15
|
+
|
|
16
|
+
# Get focal point as hash
|
|
17
|
+
def to_point
|
|
18
|
+
{ x: focal_x, y: focal_y }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Get CSS object-position value
|
|
22
|
+
def to_css
|
|
23
|
+
"object-position: #{(focal_x * 100).round(1)}% #{(focal_y * 100).round(1)}%"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Check if focal point differs from center
|
|
27
|
+
def offset_from_center?
|
|
28
|
+
(focal_x - 0.5).abs > 0.001 || (focal_y - 0.5).abs > 0.001
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Get override for specific context
|
|
32
|
+
def override_for(context)
|
|
33
|
+
overrides&.dig(context.to_s)&.with_indifferent_access
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Check if context has custom override (not using focal point)
|
|
37
|
+
def has_override?(context)
|
|
38
|
+
override = override_for(context)
|
|
39
|
+
override.present? && override[:type] != "focal"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Set override for context
|
|
43
|
+
def set_override(context, data)
|
|
44
|
+
self.overrides = (overrides || {}).merge(context.to_s => data)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Clear override for context (revert to focal point)
|
|
48
|
+
def clear_override(context)
|
|
49
|
+
set_override(context, { "type" => "focal" })
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Reset to center
|
|
53
|
+
def reset!
|
|
54
|
+
update!(focal_x: 0.5, focal_y: 0.5)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def validate_overrides_structure
|
|
60
|
+
return if overrides.blank?
|
|
61
|
+
|
|
62
|
+
overrides.each do |context, override|
|
|
63
|
+
next if override.blank?
|
|
64
|
+
unless VALID_OVERRIDE_TYPES.include?(override["type"])
|
|
65
|
+
errors.add(:overrides, "has invalid type for context #{context}")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module Railspress
|
|
2
|
+
class Import < ApplicationRecord
|
|
3
|
+
STATUSES = %w[pending processing completed failed].freeze
|
|
4
|
+
IMPORT_TYPES = %w[posts].freeze
|
|
5
|
+
|
|
6
|
+
validates :import_type, presence: true, inclusion: { in: IMPORT_TYPES }
|
|
7
|
+
validates :status, presence: true, inclusion: { in: STATUSES }
|
|
8
|
+
|
|
9
|
+
scope :by_type, ->(type) { where(import_type: type) }
|
|
10
|
+
scope :recent, -> { order(created_at: :desc).limit(10) }
|
|
11
|
+
scope :pending, -> { where(status: "pending") }
|
|
12
|
+
scope :processing, -> { where(status: "processing") }
|
|
13
|
+
scope :completed, -> { where(status: "completed") }
|
|
14
|
+
scope :failed, -> { where(status: "failed") }
|
|
15
|
+
|
|
16
|
+
def pending?
|
|
17
|
+
status == "pending"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def processing?
|
|
21
|
+
status == "processing"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def completed?
|
|
25
|
+
status == "completed"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def failed?
|
|
29
|
+
status == "failed"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def mark_processing!
|
|
33
|
+
update!(status: "processing")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def mark_completed!
|
|
37
|
+
update!(status: "completed")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def mark_failed!
|
|
41
|
+
update!(status: "failed")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def add_error(message)
|
|
45
|
+
errors_array = parsed_errors
|
|
46
|
+
errors_array << message
|
|
47
|
+
update!(error_messages: errors_array.to_json, error_count: errors_array.size)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def increment_success!
|
|
51
|
+
increment!(:success_count)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def increment_total!
|
|
55
|
+
increment!(:total_count)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def parsed_errors
|
|
59
|
+
return [] if error_messages.blank?
|
|
60
|
+
JSON.parse(error_messages)
|
|
61
|
+
rescue JSON::ParserError
|
|
62
|
+
[]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
module Railspress
|
|
2
2
|
class Post < ApplicationRecord
|
|
3
|
+
include Railspress::Entity
|
|
4
|
+
include Railspress::Taggable
|
|
5
|
+
include Railspress::HasFocalPoint
|
|
6
|
+
|
|
7
|
+
# === Entity Field Declarations ===
|
|
8
|
+
# These fields use the Entity system for type detection and config introspection.
|
|
9
|
+
# Post is NOT registered as an entity (it keeps its dedicated controller/views).
|
|
10
|
+
railspress_fields :title, :slug, :excerpt
|
|
11
|
+
railspress_fields :meta_title, :meta_description
|
|
12
|
+
railspress_fields :content # auto-detects :rich_text
|
|
13
|
+
railspress_fields :header_image, as: :attachment
|
|
14
|
+
railspress_fields :published_at, :reading_time
|
|
15
|
+
|
|
16
|
+
# Fields NOT declared above (Entity doesn't support these types yet):
|
|
17
|
+
# - category_id (belongs_to association)
|
|
18
|
+
# - status (enum)
|
|
19
|
+
# - author_id (configurable polymorphic)
|
|
20
|
+
# - tags (has_many through)
|
|
21
|
+
# See .ai/FUTURE_ENTITY.md for planned Entity enhancements
|
|
22
|
+
|
|
23
|
+
railspress_label "Posts"
|
|
24
|
+
|
|
25
|
+
# === Associations ===
|
|
3
26
|
belongs_to :category, optional: true
|
|
4
27
|
# Author association - only functional when Railspress.authors_enabled?
|
|
5
28
|
# The author class is configured via Railspress.configure { |c| c.author_class_name = "User" }
|
|
@@ -11,37 +34,97 @@ module Railspress
|
|
|
11
34
|
def author=(user)
|
|
12
35
|
self.author_id = user&.id
|
|
13
36
|
end
|
|
14
|
-
has_many :post_tags, dependent: :destroy
|
|
15
|
-
has_many :tags, through: :post_tags
|
|
16
|
-
|
|
17
37
|
has_rich_text :content
|
|
18
|
-
has_one_attached :header_image
|
|
38
|
+
has_one_attached :header_image do |attachable|
|
|
39
|
+
# Apply configured variants from host app's initializer
|
|
40
|
+
# e.g., config.post_image_variants = { hero: { resize_to_fill: [1920, 1080] } }
|
|
41
|
+
# WebP format is automatically applied for web optimization
|
|
42
|
+
Railspress.post_image_variants.each do |name, options|
|
|
43
|
+
attachable.variant name, **options.merge(format: :webp)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
has_focal_point :header_image
|
|
19
47
|
|
|
20
48
|
# Virtual attribute for removing header image via checkbox
|
|
21
49
|
attr_accessor :remove_header_image
|
|
22
50
|
before_save :purge_header_image, if: -> { remove_header_image == "1" }
|
|
23
51
|
|
|
24
|
-
enum :status, { draft: 0, published: 1 }, default: :draft
|
|
52
|
+
enum :status, { draft: 0, published: 1 }, default: :draft, scopes: false
|
|
53
|
+
|
|
54
|
+
# Predicate: post is scheduled for future publication
|
|
55
|
+
def scheduled?
|
|
56
|
+
published_at.present? && published_at > Time.current
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Predicate: post is live (published_at in past or present)
|
|
60
|
+
def live?
|
|
61
|
+
published_at.present? && published_at <= Time.current
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Returns the status to display in UI (accounts for scheduled state)
|
|
65
|
+
def display_status
|
|
66
|
+
return "scheduled" if published? && scheduled?
|
|
67
|
+
status
|
|
68
|
+
end
|
|
25
69
|
|
|
26
70
|
validates :title, presence: true
|
|
27
71
|
validates :slug, presence: true, uniqueness: true
|
|
28
72
|
|
|
29
73
|
before_validation :generate_slug, if: -> { slug.blank? && title.present? }
|
|
30
74
|
before_save :set_published_at
|
|
75
|
+
before_save :set_reading_time, if: -> { reading_time.blank? && content.present? }
|
|
31
76
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
scope :published, -> { where(status: :published).where
|
|
77
|
+
# Generic scopes (ordered, recent) and pagination (page) provided by Entity concern
|
|
78
|
+
# Post-specific scopes below:
|
|
79
|
+
scope :published, -> { where(status: :published).where(published_at: ..Time.current) }
|
|
35
80
|
scope :drafts, -> { where(status: :draft) }
|
|
81
|
+
scope :scheduled, -> { where(status: :published).where("published_at > ?", Time.current) }
|
|
82
|
+
scope :live, -> { published } # Alias for semantic clarity
|
|
36
83
|
scope :by_author, ->(author) { where(author_id: author.id) }
|
|
84
|
+
scope :search, ->(query) { where("title ILIKE ?", "%#{query}%") if query.present? }
|
|
85
|
+
scope :by_category, ->(category_id) { where(category_id: category_id) if category_id.present? }
|
|
86
|
+
scope :by_status, ->(status) {
|
|
87
|
+
case status.to_s
|
|
88
|
+
when "scheduled" then scheduled
|
|
89
|
+
when "published" then published
|
|
90
|
+
when "draft" then drafts
|
|
91
|
+
else all
|
|
92
|
+
end
|
|
93
|
+
}
|
|
94
|
+
scope :sorted_by, ->(column, direction) {
|
|
95
|
+
direction = direction.to_s.downcase == "desc" ? :desc : :asc
|
|
96
|
+
dir_sql = direction == :desc ? "DESC" : "ASC"
|
|
97
|
+
case column.to_s
|
|
98
|
+
when "title"
|
|
99
|
+
order(title: direction)
|
|
100
|
+
when "category"
|
|
101
|
+
left_joins(:category).order(Arel.sql("railspress_categories.name #{dir_sql} NULLS LAST"))
|
|
102
|
+
when "status"
|
|
103
|
+
order(status: direction)
|
|
104
|
+
when "reading_time"
|
|
105
|
+
order(reading_time: direction)
|
|
106
|
+
when "published_at"
|
|
107
|
+
order(Arel.sql("published_at #{dir_sql} NULLS LAST"))
|
|
108
|
+
when "created_at"
|
|
109
|
+
order(created_at: direction)
|
|
110
|
+
else
|
|
111
|
+
order(created_at: :desc)
|
|
112
|
+
end
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# Calculate reading time from content word count
|
|
116
|
+
def calculate_reading_time
|
|
117
|
+
return 1 unless content.present?
|
|
37
118
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
119
|
+
words_per_minute = Railspress.words_per_minute
|
|
120
|
+
word_count = content.to_plain_text.split(/\s+/).count
|
|
121
|
+
minutes = (word_count.to_f / words_per_minute).ceil
|
|
122
|
+
[ minutes, 1 ].max
|
|
41
123
|
end
|
|
42
124
|
|
|
43
|
-
|
|
44
|
-
|
|
125
|
+
# Display reading time with fallback to calculated value
|
|
126
|
+
def reading_time_display
|
|
127
|
+
reading_time.presence || calculate_reading_time
|
|
45
128
|
end
|
|
46
129
|
|
|
47
130
|
private
|
|
@@ -60,11 +143,15 @@ module Railspress
|
|
|
60
143
|
end
|
|
61
144
|
|
|
62
145
|
def set_published_at
|
|
146
|
+
# Only auto-set if publishing and no date was manually provided
|
|
63
147
|
if published? && published_at.nil?
|
|
64
148
|
self.published_at = Time.current
|
|
65
|
-
elsif draft?
|
|
66
|
-
self.published_at = nil
|
|
67
149
|
end
|
|
150
|
+
# Note: We no longer clear published_at for drafts - allow scheduling
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def set_reading_time
|
|
154
|
+
self.reading_time = calculate_reading_time
|
|
68
155
|
end
|
|
69
156
|
|
|
70
157
|
def purge_header_image
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
require "zip"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module Railspress
|
|
5
|
+
class PostExportProcessor
|
|
6
|
+
attr_reader :export, :errors, :export_dir
|
|
7
|
+
|
|
8
|
+
def initialize(export:)
|
|
9
|
+
@export = export
|
|
10
|
+
@errors = []
|
|
11
|
+
@export_dir = nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def process!
|
|
15
|
+
export.mark_processing!
|
|
16
|
+
|
|
17
|
+
setup_export_directory
|
|
18
|
+
export_all_posts
|
|
19
|
+
create_zip_file
|
|
20
|
+
|
|
21
|
+
if export.error_count > 0 && export.success_count == 0
|
|
22
|
+
export.mark_failed!
|
|
23
|
+
else
|
|
24
|
+
export.mark_completed!
|
|
25
|
+
end
|
|
26
|
+
ensure
|
|
27
|
+
cleanup_export_directory
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def setup_export_directory
|
|
33
|
+
@export_dir = Rails.root.join("tmp", "exports", "#{export.id}_#{Time.current.to_i}")
|
|
34
|
+
FileUtils.mkdir_p(@export_dir)
|
|
35
|
+
FileUtils.mkdir_p(File.join(@export_dir, "images"))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def cleanup_export_directory
|
|
39
|
+
return unless @export_dir && Dir.exist?(@export_dir)
|
|
40
|
+
FileUtils.rm_rf(@export_dir)
|
|
41
|
+
rescue => e
|
|
42
|
+
Rails.logger.warn "Failed to cleanup export tmp files: #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def export_all_posts
|
|
46
|
+
Post.find_each do |post|
|
|
47
|
+
export.increment_total!
|
|
48
|
+
export_post(post)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def export_post(post)
|
|
53
|
+
filename = generate_filename(post)
|
|
54
|
+
filepath = File.join(@export_dir, filename)
|
|
55
|
+
|
|
56
|
+
frontmatter = build_frontmatter(post)
|
|
57
|
+
content = extract_content(post)
|
|
58
|
+
|
|
59
|
+
markdown = generate_markdown(frontmatter, content)
|
|
60
|
+
File.write(filepath, markdown)
|
|
61
|
+
|
|
62
|
+
export_header_image(post) if post.header_image.attached?
|
|
63
|
+
|
|
64
|
+
export.increment_success!
|
|
65
|
+
rescue => e
|
|
66
|
+
export.add_error("#{post.title}: #{e.message}")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def generate_filename(post)
|
|
70
|
+
slug = post.slug.presence || post.title.parameterize
|
|
71
|
+
"#{slug}.md"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def build_frontmatter(post)
|
|
75
|
+
fm = {
|
|
76
|
+
"title" => post.title,
|
|
77
|
+
"slug" => post.slug,
|
|
78
|
+
"status" => post.status
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fm["published_at"] = post.published_at.to_date.to_s if post.published_at.present?
|
|
82
|
+
fm["category"] = post.category.name if post.category.present?
|
|
83
|
+
fm["tags"] = post.tags.pluck(:name).join(", ") if post.tags.any?
|
|
84
|
+
fm["meta_title"] = post.meta_title if post.meta_title.present?
|
|
85
|
+
fm["meta_description"] = post.meta_description if post.meta_description.present?
|
|
86
|
+
|
|
87
|
+
if Railspress.authors_enabled? && post.respond_to?(:author) && post.author.present?
|
|
88
|
+
display_method = Railspress.author_display_method
|
|
89
|
+
fm["author"] = post.author.public_send(display_method)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
if post.header_image.attached?
|
|
93
|
+
fm["header_image"] = "images/#{post.slug}.#{header_image_extension(post)}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
fm
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def header_image_extension(post)
|
|
100
|
+
post.header_image.filename.extension.downcase
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def extract_content(post)
|
|
104
|
+
return "" unless post.content.present?
|
|
105
|
+
|
|
106
|
+
# ActionText stores HTML, we'll keep it as-is since redcarpet doesn't reverse
|
|
107
|
+
# Most markdown parsers handle inline HTML fine
|
|
108
|
+
post.content.body.to_html
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def generate_markdown(frontmatter, content)
|
|
112
|
+
yaml = frontmatter.to_yaml
|
|
113
|
+
"#{yaml}---\n\n#{content}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def export_header_image(post)
|
|
117
|
+
return unless post.header_image.attached?
|
|
118
|
+
|
|
119
|
+
extension = header_image_extension(post)
|
|
120
|
+
image_filename = "#{post.slug}.#{extension}"
|
|
121
|
+
image_path = File.join(@export_dir, "images", image_filename)
|
|
122
|
+
|
|
123
|
+
File.open(image_path, "wb") do |file|
|
|
124
|
+
file.write(post.header_image.download)
|
|
125
|
+
end
|
|
126
|
+
rescue => e
|
|
127
|
+
Rails.logger.warn "Failed to export header image for #{post.slug}: #{e.message}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def create_zip_file
|
|
131
|
+
timestamp = Time.current.strftime("%Y%m%d_%H%M%S")
|
|
132
|
+
zip_filename = "posts_export_#{timestamp}.zip"
|
|
133
|
+
zip_path = Rails.root.join("tmp", zip_filename)
|
|
134
|
+
|
|
135
|
+
Zip::File.open(zip_path, create: true) do |zipfile|
|
|
136
|
+
add_directory_to_zip(zipfile, @export_dir, "")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Attach the zip to the export record
|
|
140
|
+
export.file.attach(
|
|
141
|
+
io: File.open(zip_path),
|
|
142
|
+
filename: zip_filename,
|
|
143
|
+
content_type: "application/zip"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
export.update!(filename: zip_filename)
|
|
147
|
+
|
|
148
|
+
# Clean up the temp zip file
|
|
149
|
+
FileUtils.rm_f(zip_path)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def add_directory_to_zip(zipfile, dir, prefix)
|
|
153
|
+
Dir.glob(File.join(dir, "**", "*")).each do |file|
|
|
154
|
+
next if File.directory?(file)
|
|
155
|
+
|
|
156
|
+
relative_path = file.sub("#{dir}/", "")
|
|
157
|
+
entry_name = prefix.present? ? File.join(prefix, relative_path) : relative_path
|
|
158
|
+
zipfile.add(entry_name, file)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|