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,382 @@
1
+ require "yaml"
2
+ require "zip"
3
+ require "open-uri"
4
+ require "fileutils"
5
+ require "redcarpet"
6
+
7
+ module Railspress
8
+ class PostImportProcessor
9
+ MARKDOWN_EXTENSIONS = %w[.md .markdown].freeze
10
+ TEXT_EXTENSIONS = %w[.txt].freeze
11
+ ZIP_EXTENSIONS = %w[.zip].freeze
12
+ IMAGE_EXTENSIONS = %w[.jpg .jpeg .png .gif .webp].freeze
13
+ FRONTMATTER_REGEX = /\A---\s*\n(.*?\n?)^---\s*\n/m
14
+
15
+ attr_reader :import, :file_path, :errors, :extract_dir
16
+
17
+ def initialize(import:, file_path:)
18
+ @import = import
19
+ @file_path = file_path
20
+ @errors = []
21
+ @extract_dir = nil
22
+ end
23
+
24
+ def process!
25
+ import.mark_processing!
26
+
27
+ process_file(file_path)
28
+
29
+ if import.error_count > 0
30
+ import.mark_failed! if import.success_count == 0
31
+ import.mark_completed! if import.success_count > 0
32
+ else
33
+ import.mark_completed!
34
+ end
35
+ end
36
+
37
+ def process_file(path, base_dir: nil)
38
+ extension = File.extname(path).downcase
39
+
40
+ if MARKDOWN_EXTENSIONS.include?(extension)
41
+ process_markdown(path, base_dir: base_dir)
42
+ elsif TEXT_EXTENSIONS.include?(extension)
43
+ process_text(path, base_dir: base_dir)
44
+ elsif ZIP_EXTENSIONS.include?(extension)
45
+ process_zip(path)
46
+ else
47
+ import.add_error("Unsupported file type: #{File.basename(path)}")
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def process_zip(path)
54
+ @extract_dir = Rails.root.join("tmp", "imports", "#{import.id}_#{Time.current.to_i}")
55
+ FileUtils.mkdir_p(@extract_dir)
56
+
57
+ begin
58
+ # Extract all files from zip
59
+ Zip::File.open(path) do |zip_file|
60
+ zip_file.each do |entry|
61
+ next if entry.name.start_with?("__MACOSX", ".")
62
+
63
+ # rubyzip 3+ expects extraction with a destination directory.
64
+ destination = File.join(@extract_dir.to_s, entry.name)
65
+ entry.extract(entry.name, destination_directory: @extract_dir.to_s) unless File.exist?(destination)
66
+ end
67
+ end
68
+
69
+ # Find and process all markdown/text files
70
+ discover_files(@extract_dir).each do |file_path|
71
+ process_file(file_path, base_dir: @extract_dir)
72
+ end
73
+ rescue Zip::Error => e
74
+ import.add_error("#{File.basename(path)}: Invalid zip file - #{e.message}")
75
+ rescue => e
76
+ import.add_error("#{File.basename(path)}: #{e.message}")
77
+ ensure
78
+ cleanup_tmp_files
79
+ end
80
+ end
81
+
82
+ def discover_files(directory)
83
+ files = []
84
+ Dir.glob(File.join(directory, "**", "*")).each do |path|
85
+ next unless File.file?(path)
86
+ ext = File.extname(path).downcase
87
+ files << path if MARKDOWN_EXTENSIONS.include?(ext) || TEXT_EXTENSIONS.include?(ext)
88
+ end
89
+ files.sort
90
+ end
91
+
92
+ def cleanup_tmp_files
93
+ return unless @extract_dir && Dir.exist?(@extract_dir)
94
+ FileUtils.rm_rf(@extract_dir)
95
+ rescue => e
96
+ Rails.logger.warn "Failed to cleanup import tmp files: #{e.message}"
97
+ end
98
+
99
+ def process_markdown(path, base_dir: nil)
100
+ import.increment_total!
101
+ content = File.read(path, encoding: "UTF-8")
102
+
103
+ frontmatter, body = extract_frontmatter(content)
104
+
105
+ if body.blank? && frontmatter[:title].blank?
106
+ import.add_error("#{File.basename(path)}: No content or title found")
107
+ return
108
+ end
109
+
110
+ create_post(frontmatter, body, path, base_dir: base_dir)
111
+ rescue => e
112
+ import.add_error("#{File.basename(path)}: #{e.message}")
113
+ end
114
+
115
+ def process_text(path, base_dir: nil)
116
+ import.increment_total!
117
+ content = File.read(path, encoding: "UTF-8")
118
+
119
+ if content.blank?
120
+ import.add_error("#{File.basename(path)}: File is empty")
121
+ return
122
+ end
123
+
124
+ # Title from filename: "my-blog-post.txt" -> "My Blog Post"
125
+ title = File.basename(path, ".*").tr("-_", " ").titleize
126
+ frontmatter = { title: title }
127
+
128
+ create_post(frontmatter, content, path, base_dir: base_dir)
129
+ rescue => e
130
+ import.add_error("#{File.basename(path)}: #{e.message}")
131
+ end
132
+
133
+ def extract_frontmatter(content)
134
+ if content.match?(FRONTMATTER_REGEX)
135
+ match = content.match(FRONTMATTER_REGEX)
136
+ yaml_content = match[1]
137
+ body = content.sub(FRONTMATTER_REGEX, "").strip
138
+
139
+ begin
140
+ parsed = YAML.safe_load(yaml_content, permitted_classes: [ Date, Time, DateTime ], symbolize_names: true) || {}
141
+ [ parsed, body ]
142
+ rescue Psych::SyntaxError => e
143
+ raise "Invalid YAML frontmatter: #{e.message}"
144
+ end
145
+ else
146
+ # No frontmatter, treat entire content as body
147
+ [ {}, content.strip ]
148
+ end
149
+ end
150
+
151
+ def create_post(frontmatter, body, source_path, base_dir: nil)
152
+ title = frontmatter[:title]
153
+
154
+ if title.blank?
155
+ # Try to get title from first heading or filename
156
+ title = extract_title_from_body(body) || File.basename(source_path, ".*").tr("-_", " ").titleize
157
+ end
158
+
159
+ if title.blank?
160
+ import.add_error("#{File.basename(source_path)}: No title found")
161
+ return
162
+ end
163
+
164
+ post = Post.new(
165
+ title: title,
166
+ slug: frontmatter[:slug].presence,
167
+ status: parse_status(frontmatter[:status]),
168
+ published_at: parse_date(frontmatter[:published_at]),
169
+ meta_title: frontmatter[:meta_title].presence,
170
+ meta_description: frontmatter[:meta_description].presence
171
+ )
172
+
173
+ # Set rich text content (convert markdown to HTML)
174
+ post.content = render_markdown(body) if body.present?
175
+
176
+ # Handle associations
177
+ assign_author(post, frontmatter[:author])
178
+ assign_category(post, frontmatter[:category])
179
+ assign_tags(post, frontmatter[:tags])
180
+
181
+ if post.save
182
+ # Attach header image after save (needs post.id for ActiveStorage)
183
+ attach_header_image(post, frontmatter[:header_image], source_path, base_dir)
184
+ import.increment_success!
185
+ else
186
+ import.add_error("#{File.basename(source_path)}: #{post.errors.full_messages.join(', ')}")
187
+ end
188
+
189
+ post
190
+ end
191
+
192
+ def attach_header_image(post, header_image_value, source_path, base_dir)
193
+ return unless header_image_value.present?
194
+ return unless Railspress.post_images_enabled?
195
+
196
+ if url?(header_image_value)
197
+ attach_image_from_url(post, header_image_value)
198
+ elsif base_dir
199
+ attach_image_from_zip(post, header_image_value, source_path, base_dir)
200
+ end
201
+ rescue => e
202
+ Rails.logger.warn "Failed to attach header image for post #{post.id}: #{e.message}"
203
+ end
204
+
205
+ def url?(value)
206
+ value.to_s.match?(/\Ahttps?:\/\//i)
207
+ end
208
+
209
+ def attach_image_from_url(post, url)
210
+ uri = URI.parse(url)
211
+ filename = File.basename(uri.path)
212
+ filename = "header_image#{File.extname(uri.path)}" if filename.blank?
213
+
214
+ downloaded = URI.open(url)
215
+ post.header_image.attach(
216
+ io: downloaded,
217
+ filename: filename,
218
+ content_type: downloaded.content_type
219
+ )
220
+ rescue OpenURI::HTTPError, URI::InvalidURIError => e
221
+ Rails.logger.warn "Failed to download header image from #{url}: #{e.message}"
222
+ end
223
+
224
+ def attach_image_from_zip(post, relative_path, source_path, base_dir)
225
+ # Resolve relative path from the markdown file's directory or from base_dir
226
+ source_dir = File.dirname(source_path)
227
+
228
+ # Try relative to the markdown file first
229
+ image_path = File.expand_path(relative_path, source_dir)
230
+
231
+ # If not found, try relative to base_dir
232
+ unless File.exist?(image_path)
233
+ image_path = File.expand_path(relative_path, base_dir)
234
+ end
235
+
236
+ return unless File.exist?(image_path)
237
+
238
+ ext = File.extname(image_path).downcase
239
+ return unless IMAGE_EXTENSIONS.include?(ext)
240
+
241
+ content_type = case ext
242
+ when ".jpg", ".jpeg" then "image/jpeg"
243
+ when ".png" then "image/png"
244
+ when ".gif" then "image/gif"
245
+ when ".webp" then "image/webp"
246
+ else "application/octet-stream"
247
+ end
248
+
249
+ post.header_image.attach(
250
+ io: File.open(image_path),
251
+ filename: File.basename(image_path),
252
+ content_type: content_type
253
+ )
254
+ end
255
+
256
+ def extract_title_from_body(body)
257
+ # Look for first H1: # Title
258
+ if match = body.match(/^#\s+(.+)$/)
259
+ match[1].strip
260
+ end
261
+ end
262
+
263
+ def parse_status(status)
264
+ return :draft if status.blank?
265
+
266
+ status_str = status.to_s.downcase
267
+ Post.statuses.key?(status_str) ? status_str.to_sym : :draft
268
+ end
269
+
270
+ def parse_date(date)
271
+ return Date.current if date.blank?
272
+
273
+ case date
274
+ when Date, Time, DateTime
275
+ date.to_date
276
+ when String
277
+ Date.parse(date)
278
+ else
279
+ Date.current
280
+ end
281
+ rescue ArgumentError
282
+ Date.current
283
+ end
284
+
285
+ def assign_author(post, author_value)
286
+ return unless author_value.present?
287
+ return unless Railspress.authors_enabled?
288
+
289
+ author_class = Railspress.author_class
290
+ display_method = Railspress.author_display_method
291
+
292
+ # Build a case-insensitive query using the display method
293
+ # e.g., if display_method is :name, we search by name
294
+ begin
295
+ column = author_class.arel_table[display_method]
296
+ author = author_class.where(
297
+ column.lower.eq(author_value.to_s.downcase)
298
+ ).first
299
+
300
+ post.author_id = author.id if author
301
+ rescue ActiveRecord::StatementInvalid
302
+ # Column doesn't exist or other DB error - skip author assignment
303
+ nil
304
+ end
305
+ end
306
+
307
+ def assign_category(post, category_value)
308
+ return unless category_value.present?
309
+
310
+ category = Category.where("LOWER(name) = ?", category_value.to_s.downcase).first
311
+ post.category = category if category
312
+ end
313
+
314
+ def assign_tags(post, tags_value)
315
+ return unless tags_value.present?
316
+
317
+ # Tags can be string (csv) or array
318
+ csv = tags_value.is_a?(Array) ? tags_value.join(", ") : tags_value.to_s
319
+ post.tag_list = csv
320
+ end
321
+
322
+ def render_markdown(text)
323
+ return "" if text.blank?
324
+
325
+ # Strip Obsidian-style metadata before rendering
326
+ text = strip_obsidian_metadata(text)
327
+
328
+ renderer = Redcarpet::Render::HTML.new(
329
+ hard_wrap: true,
330
+ filter_html: false,
331
+ safe_links_only: true
332
+ )
333
+
334
+ markdown = Redcarpet::Markdown.new(renderer,
335
+ autolink: true,
336
+ tables: true,
337
+ fenced_code_blocks: true,
338
+ strikethrough: true,
339
+ highlight: true,
340
+ superscript: true,
341
+ no_intra_emphasis: true
342
+ )
343
+
344
+ markdown.render(text)
345
+ end
346
+
347
+ def strip_obsidian_metadata(text)
348
+ lines = text.lines
349
+ content_start = 0
350
+
351
+ lines.each_with_index do |line, index|
352
+ stripped = line.strip
353
+
354
+ # Skip empty lines at the start
355
+ next if stripped.empty?
356
+
357
+ # Skip lines that are only hashtags (Obsidian tags)
358
+ # e.g., "#current #writing #daily"
359
+ next if stripped.match?(/\A(#\S+\s*)+\z/)
360
+
361
+ # Skip lines that are project/category names followed by tags
362
+ # e.g., "projects #current #writing"
363
+ next if stripped.match?(/\A\w+(\s+#\S+)+\z/)
364
+
365
+ # Skip Obsidian task/checkbox lines
366
+ # e.g., "• [x] ⏫ ⏳ (@2023-06-08) #in-progress"
367
+ # e.g., "- [ ] Task item"
368
+ next if stripped.match?(/\A[•\-\*]\s*\[[x\s]\]/i)
369
+
370
+ # Skip lines that are just metadata markers (dates, priorities, etc.)
371
+ # e.g., "⏫ ⏳ (@2023-06-08)"
372
+ next if stripped.match?(/\A[⏫⏳📅🔺🔻📌]+|\A\(@?\d{4}-\d{2}-\d{2}\)/)
373
+
374
+ # Found actual content - this is where we start
375
+ content_start = index
376
+ break
377
+ end
378
+
379
+ lines[content_start..].join
380
+ end
381
+ end
382
+ end
@@ -1,7 +1,12 @@
1
1
  module Railspress
2
2
  class Tag < ApplicationRecord
3
- has_many :post_tags, dependent: :destroy
4
- has_many :posts, through: :post_tags
3
+ has_many :taggings, dependent: :destroy
4
+
5
+ # Explicit reverse associations for each taggable type
6
+ has_many :posts,
7
+ through: :taggings,
8
+ source: :taggable,
9
+ source_type: "Railspress::Post"
5
10
 
6
11
  validates :name, presence: true, uniqueness: { case_sensitive: false }
7
12
  validates :slug, presence: true, uniqueness: true
@@ -16,7 +21,9 @@ module Railspress
16
21
  return [] if csv_string.blank?
17
22
 
18
23
  tag_names = csv_string.split(",").map { |t| t.strip.downcase }.reject(&:blank?).uniq
19
- tag_names.map { |name| find_or_create_by(name: name) }
24
+ tag_names.map do |name|
25
+ find_by(name: name) || find_by(slug: name.parameterize) || create!(name: name)
26
+ end
20
27
  end
21
28
 
22
29
  private
@@ -0,0 +1,11 @@
1
+ module Railspress
2
+ class Tagging < ApplicationRecord
3
+ belongs_to :tag
4
+ belongs_to :taggable, polymorphic: true
5
+
6
+ validates :tag_id, uniqueness: {
7
+ scope: [ :taggable_type, :taggable_id ],
8
+ message: "has already been applied to this item"
9
+ }
10
+ end
11
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zip"
4
+ require "json"
5
+ require "fileutils"
6
+
7
+ module Railspress
8
+ class ContentExportService
9
+ Result = Struct.new(:zip_data, :filename, :group_count, :element_count, keyword_init: true)
10
+
11
+ def call
12
+ @group_count = 0
13
+ @element_count = 0
14
+
15
+ manifest = build_manifest
16
+ zip_data = build_zip(manifest)
17
+ timestamp = Time.current.strftime("%Y%m%d_%H%M%S")
18
+
19
+ Result.new(
20
+ zip_data: zip_data,
21
+ filename: "cms_content_#{timestamp}.zip",
22
+ group_count: @group_count,
23
+ element_count: @element_count
24
+ )
25
+ end
26
+
27
+ private
28
+
29
+ def build_manifest
30
+ groups = ContentGroup.active
31
+ .includes(content_elements: { image_attachment: :blob })
32
+ .order(:name)
33
+
34
+ {
35
+ "version" => 1,
36
+ "exported_at" => Time.current.iso8601,
37
+ "source" => "RailsPress CMS",
38
+ "groups" => groups.map { |group| serialize_group(group) }
39
+ }
40
+ end
41
+
42
+ def serialize_group(group)
43
+ @group_count += 1
44
+ elements = group.content_elements.active.ordered
45
+
46
+ {
47
+ "name" => group.name,
48
+ "description" => group.description,
49
+ "elements" => elements.map { |el| serialize_element(group, el) }
50
+ }
51
+ end
52
+
53
+ def serialize_element(group, element)
54
+ @element_count += 1
55
+
56
+ data = {
57
+ "name" => element.name,
58
+ "content_type" => element.content_type,
59
+ "position" => element.position,
60
+ "text_content" => element.text_content,
61
+ "required" => element.required,
62
+ "image_hint" => element.image_hint
63
+ }
64
+
65
+ if element.image? && element.image.attached?
66
+ data["image_path"] = image_path_for(group, element)
67
+ end
68
+
69
+ if element.image? && element.respond_to?(:image_focal_point)
70
+ fp = element.image_focal_point
71
+ if fp&.persisted? && fp.offset_from_center?
72
+ data["focal_point"] = { "x" => fp.focal_x, "y" => fp.focal_y }
73
+ end
74
+ end
75
+
76
+ data
77
+ end
78
+
79
+ def build_zip(manifest)
80
+ buffer = Zip::OutputStream.write_buffer do |zip|
81
+ zip.put_next_entry("content.json")
82
+ zip.write(JSON.pretty_generate(manifest))
83
+
84
+ collect_images(manifest).each do |path, element_record|
85
+ zip.put_next_entry(path)
86
+ zip.write(element_record.image.download)
87
+ end
88
+ end
89
+
90
+ buffer.string
91
+ end
92
+
93
+ def collect_images(manifest)
94
+ images = {}
95
+
96
+ manifest["groups"].each do |group_data|
97
+ group = ContentGroup.find_by(name: group_data["name"])
98
+ next unless group
99
+
100
+ group_data["elements"].each do |el_data|
101
+ next unless el_data["image_path"]
102
+
103
+ element = group.content_elements.active.find_by(name: el_data["name"])
104
+ next unless element&.image&.attached?
105
+
106
+ images[el_data["image_path"]] = element
107
+ end
108
+ end
109
+
110
+ images
111
+ end
112
+
113
+ def image_path_for(group, element)
114
+ ext = element.image.filename.extension.downcase
115
+ "images/#{sanitize_path(group.name)}/#{sanitize_path(element.name)}.#{ext}"
116
+ end
117
+
118
+ def sanitize_path(name)
119
+ name.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
120
+ end
121
+ end
122
+ end