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.
Files changed (140) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +20 -0
  3. data/README.md +195 -25
  4. data/app/assets/javascripts/railspress/admin.js +39 -0
  5. data/app/assets/javascripts/railspress/markdown_mode.js +343 -0
  6. data/app/assets/stylesheets/application.css +0 -0
  7. data/app/assets/stylesheets/railspress/admin/badges.css +70 -0
  8. data/app/assets/stylesheets/railspress/admin/base.css +25 -0
  9. data/app/assets/stylesheets/railspress/admin/buttons.css +140 -0
  10. data/app/assets/stylesheets/railspress/admin/cards.css +52 -0
  11. data/app/assets/stylesheets/railspress/admin/components/exports.css +55 -0
  12. data/app/assets/stylesheets/railspress/admin/components/focal_point.css +801 -0
  13. data/app/assets/stylesheets/railspress/admin/components/imports.css +144 -0
  14. data/app/assets/stylesheets/railspress/admin/components/lexxy.css +156 -0
  15. data/app/assets/stylesheets/railspress/admin/filters.css +73 -0
  16. data/app/assets/stylesheets/railspress/admin/flash.css +26 -0
  17. data/app/assets/stylesheets/railspress/admin/forms.css +459 -0
  18. data/app/assets/stylesheets/railspress/admin/layout.css +256 -0
  19. data/app/assets/stylesheets/railspress/admin/lists.css +24 -0
  20. data/app/assets/stylesheets/railspress/admin/page.css +111 -0
  21. data/app/assets/stylesheets/railspress/admin/responsive.css +174 -0
  22. data/app/assets/stylesheets/railspress/admin/stats.css +43 -0
  23. data/app/assets/stylesheets/railspress/admin/tables.css +163 -0
  24. data/app/assets/stylesheets/railspress/admin/utilities.css +202 -0
  25. data/app/assets/stylesheets/railspress/admin/variables.css +58 -0
  26. data/app/assets/stylesheets/railspress/application.css +44 -13
  27. data/app/controllers/railspress/admin/base_controller.rb +6 -3
  28. data/app/controllers/railspress/admin/categories_controller.rb +1 -1
  29. data/app/controllers/railspress/admin/cms_transfers_controller.rb +49 -0
  30. data/app/controllers/railspress/admin/content_element_versions_controller.rb +12 -0
  31. data/app/controllers/railspress/admin/content_elements_controller.rb +143 -0
  32. data/app/controllers/railspress/admin/content_groups_controller.rb +69 -0
  33. data/app/controllers/railspress/admin/dashboard_controller.rb +6 -0
  34. data/app/controllers/railspress/admin/entities_controller.rb +157 -0
  35. data/app/controllers/railspress/admin/exports_controller.rb +55 -0
  36. data/app/controllers/railspress/admin/focal_points_controller.rb +100 -0
  37. data/app/controllers/railspress/admin/imports_controller.rb +63 -0
  38. data/app/controllers/railspress/admin/posts_controller.rb +58 -4
  39. data/app/controllers/railspress/admin/prototypes_controller.rb +30 -0
  40. data/app/controllers/railspress/admin/tags_controller.rb +1 -1
  41. data/app/controllers/railspress/application_controller.rb +1 -0
  42. data/app/helpers/railspress/admin_helper.rb +733 -0
  43. data/app/helpers/railspress/application_helper.rb +23 -0
  44. data/app/helpers/railspress/cms_helper.rb +319 -0
  45. data/app/javascript/railspress/controllers/cms_inline_editor_controller.js +147 -0
  46. data/app/javascript/railspress/controllers/content_element_form_controller.js +15 -0
  47. data/app/javascript/railspress/controllers/crop_controller.js +224 -0
  48. data/app/javascript/railspress/controllers/dropzone_controller.js +261 -0
  49. data/app/javascript/railspress/controllers/focal_point_controller.js +124 -0
  50. data/app/javascript/railspress/controllers/image_section_controller.js +94 -0
  51. data/app/javascript/railspress/controllers/index.js +37 -0
  52. data/app/javascript/railspress/index.js +62 -0
  53. data/app/jobs/railspress/export_posts_job.rb +16 -0
  54. data/app/jobs/railspress/import_posts_job.rb +44 -0
  55. data/app/models/concerns/railspress/has_focal_point.rb +242 -0
  56. data/app/models/concerns/railspress/soft_deletable.rb +23 -0
  57. data/app/models/concerns/railspress/taggable.rb +23 -0
  58. data/app/models/railspress/content_element.rb +103 -0
  59. data/app/models/railspress/content_element_version.rb +32 -0
  60. data/app/models/railspress/content_group.rb +39 -0
  61. data/app/models/railspress/export.rb +67 -0
  62. data/app/models/railspress/focal_point.rb +70 -0
  63. data/app/models/railspress/import.rb +65 -0
  64. data/app/models/railspress/post.rb +102 -15
  65. data/app/models/railspress/post_export_processor.rb +162 -0
  66. data/app/models/railspress/post_import_processor.rb +382 -0
  67. data/app/models/railspress/tag.rb +10 -3
  68. data/app/models/railspress/tagging.rb +11 -0
  69. data/app/services/railspress/content_export_service.rb +122 -0
  70. data/app/services/railspress/content_import_service.rb +228 -0
  71. data/app/views/action_text/attachables/_remote_image.html.erb +8 -0
  72. data/app/views/active_storage/blobs/_blob.html.erb +1 -1
  73. data/app/views/layouts/railspress/admin.html.erb +3 -1
  74. data/app/views/railspress/admin/categories/index.html.erb +11 -15
  75. data/app/views/railspress/admin/cms_transfers/show.html.erb +167 -0
  76. data/app/views/railspress/admin/content_element_versions/show.html.erb +42 -0
  77. data/app/views/railspress/admin/content_elements/_form.html.erb +71 -0
  78. data/app/views/railspress/admin/content_elements/_inline_form.html.erb +32 -0
  79. data/app/views/railspress/admin/content_elements/_inline_form_frame.html.erb +6 -0
  80. data/app/views/railspress/admin/content_elements/edit.html.erb +6 -0
  81. data/app/views/railspress/admin/content_elements/index.html.erb +74 -0
  82. data/app/views/railspress/admin/content_elements/new.html.erb +6 -0
  83. data/app/views/railspress/admin/content_elements/show.html.erb +124 -0
  84. data/app/views/railspress/admin/content_groups/_form.html.erb +9 -0
  85. data/app/views/railspress/admin/content_groups/edit.html.erb +6 -0
  86. data/app/views/railspress/admin/content_groups/index.html.erb +42 -0
  87. data/app/views/railspress/admin/content_groups/new.html.erb +6 -0
  88. data/app/views/railspress/admin/content_groups/show.html.erb +92 -0
  89. data/app/views/railspress/admin/dashboard/index.html.erb +36 -1
  90. data/app/views/railspress/admin/entities/_form.html.erb +53 -0
  91. data/app/views/railspress/admin/entities/edit.html.erb +4 -0
  92. data/app/views/railspress/admin/entities/index.html.erb +74 -0
  93. data/app/views/railspress/admin/entities/new.html.erb +4 -0
  94. data/app/views/railspress/admin/entities/show.html.erb +117 -0
  95. data/app/views/railspress/admin/exports/show.html.erb +62 -0
  96. data/app/views/railspress/admin/imports/_instructions.html.erb +56 -0
  97. data/app/views/railspress/admin/imports/show.html.erb +137 -0
  98. data/app/views/railspress/admin/posts/_form.html.erb +102 -28
  99. data/app/views/railspress/admin/posts/_post_row.html.erb +40 -0
  100. data/app/views/railspress/admin/posts/index.html.erb +47 -36
  101. data/app/views/railspress/admin/posts/show.html.erb +55 -19
  102. data/app/views/railspress/admin/prototypes/image_section.html.erb +42 -0
  103. data/app/views/railspress/admin/shared/_dropzone.html.erb +84 -0
  104. data/app/views/railspress/admin/shared/_focal_point_editor.html.erb +102 -0
  105. data/app/views/railspress/admin/shared/_image_section.html.erb +159 -0
  106. data/app/views/railspress/admin/shared/_image_section_compact.html.erb +90 -0
  107. data/app/views/railspress/admin/shared/_image_section_editor.html.erb +171 -0
  108. data/app/views/railspress/admin/shared/_image_section_v2.html.erb +205 -0
  109. data/app/views/railspress/admin/shared/_sidebar.html.erb +73 -5
  110. data/app/views/railspress/admin/tags/index.html.erb +12 -16
  111. data/config/brakeman.ignore +18 -0
  112. data/config/importmap.rb +23 -0
  113. data/config/routes.rb +62 -1
  114. data/db/migrate/20241218000004_create_railspress_post_tags.rb +1 -1
  115. data/db/migrate/20241218000005_create_railspress_imports.rb +21 -0
  116. data/db/migrate/20241218000006_create_railspress_exports.rb +20 -0
  117. data/db/migrate/20241218000007_create_railspress_taggings.rb +20 -0
  118. data/db/migrate/20241218000008_drop_railspress_post_tags.rb +14 -0
  119. data/db/migrate/20241218000010_add_reading_time_to_railspress_posts.rb +5 -0
  120. data/db/migrate/20250105000002_create_railspress_focal_points.rb +20 -0
  121. data/db/migrate/20260206000001_create_railspress_content_groups.rb +18 -0
  122. data/db/migrate/20260206000002_create_railspress_content_elements.rb +21 -0
  123. data/db/migrate/20260206000003_create_railspress_content_element_versions.rb +20 -0
  124. data/db/migrate/20260207000001_add_unique_index_to_content_elements.rb +11 -0
  125. data/db/migrate/20260211112812_add_image_hint_to_railspress_content_elements.rb +7 -0
  126. data/db/migrate/20260211154040_add_required_to_railspress_content_elements.rb +5 -0
  127. data/lib/generators/railspress/entity/entity_generator.rb +89 -0
  128. data/lib/generators/railspress/entity/templates/migration.rb.tt +13 -0
  129. data/lib/generators/railspress/entity/templates/model.rb.tt +21 -0
  130. data/lib/generators/railspress/install/install_generator.rb +51 -40
  131. data/lib/generators/railspress/install/templates/initializer.rb +29 -0
  132. data/lib/railspress/engine.rb +38 -0
  133. data/lib/railspress/entity.rb +239 -0
  134. data/lib/railspress/version.rb +1 -1
  135. data/lib/railspress.rb +198 -8
  136. data/lib/tasks/railspress_tasks.rake +49 -4
  137. metadata +215 -21
  138. data/MIT-LICENSE +0 -20
  139. data/app/assets/stylesheets/railspress/admin.css +0 -1207
  140. 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
- scope :ordered, -> { order(created_at: :desc) }
33
- scope :recent, -> { ordered.limit(10) }
34
- scope :published, -> { where(status: :published).where.not(published_at: nil) }
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
- # Accepts CSV string and syncs tags
39
- def tag_list=(csv_string)
40
- self.tags = Tag.from_csv(csv_string)
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
- def tag_list
44
- tags.pluck(:name).join(", ")
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