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,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 :
|
|
4
|
-
|
|
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
|
|
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
|