lato_cms 3.0.13 → 3.1.1
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/app/assets/javascripts/lato_cms/controllers/lato_cms_media_field_controller.js +143 -0
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_picker_controller.js +110 -0
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_reorder_controller.js +37 -0
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_reload_on_modal_close_controller.js +44 -0
- data/app/assets/stylesheets/lato_cms/application.scss +131 -158
- data/app/controllers/lato_cms/application_controller.rb +4 -0
- data/app/controllers/lato_cms/media_controller.rb +121 -0
- data/app/controllers/lato_cms/pages_controller.rb +75 -91
- data/app/helpers/lato_cms/media_helper.rb +50 -0
- data/app/jobs/lato_cms/export_sitemap_csv_job.rb +33 -0
- data/app/jobs/lato_cms/generate_alt_text_job.rb +21 -0
- data/app/jobs/lato_cms/generate_video_poster_job.rb +7 -5
- data/app/jobs/lato_cms/translate_component_fields_job.rb +16 -0
- data/app/models/lato_cms/llm_client.rb +31 -0
- data/app/models/lato_cms/media.rb +270 -0
- data/app/models/lato_cms/page.rb +40 -0
- data/app/models/lato_cms/page_field.rb +52 -80
- data/app/models/lato_cms/page_field_media.rb +8 -0
- data/app/views/lato_cms/media/_form_create.html.erb +20 -0
- data/app/views/lato_cms/media/_form_update.html.erb +60 -0
- data/app/views/lato_cms/media/create.html.erb +13 -0
- data/app/views/lato_cms/media/index.html.erb +22 -0
- data/app/views/lato_cms/media/picker_action.html.erb +98 -0
- data/app/views/lato_cms/media/update.html.erb +13 -0
- data/app/views/lato_cms/pages/_component_accordion.html.erb +15 -0
- data/app/views/lato_cms/pages/fields/_file.html.erb +4 -55
- data/app/views/lato_cms/pages/fields/_gallery.html.erb +4 -60
- data/app/views/lato_cms/pages/fields/_image.html.erb +4 -68
- data/app/views/lato_cms/pages/fields/_media_field.html.erb +62 -0
- data/app/views/lato_cms/pages/fields/_media_item.html.erb +27 -0
- data/app/views/lato_cms/pages/fields/_video.html.erb +4 -69
- data/app/views/lato_cms/pages/index.html.erb +17 -0
- data/app/views/lato_cms/pages/show.html.erb +2 -1
- data/config/locales/en.yml +36 -12
- data/config/locales/it.yml +36 -12
- data/config/routes.rb +12 -0
- data/db/migrate/20260728175413_create_lato_cms_media.rb +11 -0
- data/db/migrate/20260728175414_create_lato_cms_page_field_media.rb +12 -0
- data/db/migrate/20260729120000_change_lato_cms_media_alt_text_to_translations.rb +25 -0
- data/lib/lato_cms/config.rb +12 -1
- data/lib/lato_cms/version.rb +1 -1
- metadata +38 -5
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_file_field_controller.js +0 -225
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_gallery_field_controller.js +0 -190
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_image_field_controller.js +0 -169
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_video_field_controller.js +0 -180
|
@@ -9,10 +9,6 @@ module LatoCms
|
|
|
9
9
|
translations link_translation_action unlink_translation_action
|
|
10
10
|
].freeze
|
|
11
11
|
|
|
12
|
-
# Field types whose value lives in Active Storage attachments instead of
|
|
13
|
-
# `value`, so `required` must be checked against the attached files.
|
|
14
|
-
ATTACHMENT_FIELD_TYPES = %w[file image video gallery].freeze
|
|
15
|
-
|
|
16
12
|
before_action { active_sidebar(:lato_cms_pages) }
|
|
17
13
|
before_action :authenticate_lato_cms_admin, only: ADMIN_ONLY_ACTIONS
|
|
18
14
|
|
|
@@ -30,6 +26,20 @@ module LatoCms
|
|
|
30
26
|
)
|
|
31
27
|
end
|
|
32
28
|
|
|
29
|
+
def sitemap_export_action
|
|
30
|
+
@operation = Lato::Operation.generate(
|
|
31
|
+
'LatoCms::ExportSitemapCsvJob',
|
|
32
|
+
{ lato_spaces_group_id: @session.get(:spaces_group_id) },
|
|
33
|
+
@session.user_id
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if @operation.start
|
|
37
|
+
redirect_to lato.operation_path(@operation)
|
|
38
|
+
else
|
|
39
|
+
redirect_to lato_cms.pages_path, alert: t('lato_cms.sitemap_export_failed')
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
33
43
|
def show
|
|
34
44
|
@page = query_pages.find(params[:id])
|
|
35
45
|
@page.fields.load
|
|
@@ -143,15 +153,37 @@ module LatoCms
|
|
|
143
153
|
@page = query_pages.find(params[:id])
|
|
144
154
|
source = @page.translations.find_by(id: params[:source_page_id])
|
|
145
155
|
template_component_id = params[:template_component_id].to_s
|
|
156
|
+
translate = ActiveModel::Type::Boolean.new.cast(params[:translate]) && LatoCms.config.llm_configured?
|
|
146
157
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
format.html { redirect_to lato_cms.pages_show_path(@page), notice: t('lato_cms.component_cloned') }
|
|
150
|
-
format.json { render json: { message: t('lato_cms.component_cloned') } }
|
|
151
|
-
else
|
|
158
|
+
unless source && clone_component_fields(source, @page, template_component_id)
|
|
159
|
+
respond_to do |format|
|
|
152
160
|
format.html { redirect_to lato_cms.pages_show_path(@page), alert: t('lato_cms.component_clone_failed') }
|
|
153
161
|
format.json { render json: { error: t('lato_cms.component_clone_failed') }, status: :unprocessable_entity }
|
|
154
162
|
end
|
|
163
|
+
return
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
unless translate
|
|
167
|
+
respond_to do |format|
|
|
168
|
+
format.html { redirect_to lato_cms.pages_show_path(@page), notice: t('lato_cms.component_cloned') }
|
|
169
|
+
format.json { render json: { message: t('lato_cms.component_cloned') } }
|
|
170
|
+
end
|
|
171
|
+
return
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# The clone above already happened synchronously and succeeded; only the
|
|
175
|
+
# translation step (a real network call to the LLM) runs as a
|
|
176
|
+
# Lato::Operation, so it can never block this request or time it out.
|
|
177
|
+
operation = Lato::Operation.generate(
|
|
178
|
+
'LatoCms::TranslateComponentFieldsJob',
|
|
179
|
+
{ page_id: @page.id, template_component_id: template_component_id },
|
|
180
|
+
@session.user_id
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
if operation.start
|
|
184
|
+
redirect_to lato.operation_path(operation)
|
|
185
|
+
else
|
|
186
|
+
redirect_to lato_cms.pages_show_path(@page), alert: t('lato_cms.component_clone_translate_failed')
|
|
155
187
|
end
|
|
156
188
|
end
|
|
157
189
|
|
|
@@ -206,7 +238,12 @@ module LatoCms
|
|
|
206
238
|
private
|
|
207
239
|
|
|
208
240
|
# Replaces the target page's fields for a component with copies of the source
|
|
209
|
-
# page's fields for the same component.
|
|
241
|
+
# page's fields for the same component. Media references are shared (not
|
|
242
|
+
# duplicated), so a cloned video field's poster stays correct for free.
|
|
243
|
+
# Media's own alt text is untouched here — it's translated independently
|
|
244
|
+
# by its own LLM job (see LatoCms::Media), not as part of cloning a
|
|
245
|
+
# page's fields. Translation, when requested, is a separate async step
|
|
246
|
+
# (see clone_component_action) on top of this already-committed clone.
|
|
210
247
|
def clone_component_fields(source, target, template_component_id)
|
|
211
248
|
# Components are template-specific: only clone between pages using the same template.
|
|
212
249
|
return false unless source.template_id == target.template_id
|
|
@@ -225,7 +262,7 @@ module LatoCms
|
|
|
225
262
|
field_id: src.field_id,
|
|
226
263
|
value: src.value
|
|
227
264
|
)
|
|
228
|
-
src.
|
|
265
|
+
field.replace_media!(src.media.pluck(:id))
|
|
229
266
|
end
|
|
230
267
|
end
|
|
231
268
|
|
|
@@ -288,41 +325,33 @@ module LatoCms
|
|
|
288
325
|
field_config = component&.dig('fields', config_field_id)
|
|
289
326
|
field_type = field_config&.dig('type') || 'string'
|
|
290
327
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
328
|
+
if LatoCms::PageField::ATTACHMENT_FIELD_TYPES.include?(field_type)
|
|
329
|
+
# Bootstrap-persist to get an id so join rows can reference it before
|
|
330
|
+
# the real, validated save below (the one that actually enforces
|
|
331
|
+
# `required`, now possible since media presence is a real association).
|
|
332
|
+
field.save(validate: false) if field.new_record?
|
|
333
|
+
assign_media_field_value(field, field_type, field_data)
|
|
334
|
+
else
|
|
335
|
+
assign_scalar_field_value(field, field_type, field_data)
|
|
296
336
|
end
|
|
297
337
|
|
|
298
|
-
|
|
299
|
-
# cannot validate `required` for attachment fields. Backstop the
|
|
300
|
-
# client-side enforcement: a required field must end the save with at
|
|
301
|
-
# least one file, otherwise required would be bypassable.
|
|
302
|
-
if ATTACHMENT_FIELD_TYPES.include?(field_type) && field_config&.dig("required") == true && field.files.reload.empty?
|
|
303
|
-
errors << { field_id: persisted_field_id, errors: [t("lato_cms.field_required_attachment_error")] }
|
|
304
|
-
end
|
|
338
|
+
errors << { field_id: persisted_field_id, errors: field.errors.full_messages } unless field.save
|
|
305
339
|
end
|
|
306
340
|
|
|
307
|
-
def
|
|
341
|
+
def assign_media_field_value(field, field_type, field_data)
|
|
308
342
|
case field_type
|
|
309
343
|
when 'file'
|
|
310
|
-
field.
|
|
311
|
-
|
|
312
|
-
when 'image'
|
|
313
|
-
field
|
|
314
|
-
replace_field_image(field, field_data)
|
|
315
|
-
when 'video'
|
|
316
|
-
field.save if field.new_record?
|
|
317
|
-
replace_field_video(field, field_data)
|
|
344
|
+
ids = field.field_settings['multiple'] == true ? Array(field_data[:media_ids]) : [field_data[:media_id]]
|
|
345
|
+
assign_media_field(field, ids)
|
|
346
|
+
when 'image', 'video'
|
|
347
|
+
assign_media_field(field, [field_data[:media_id]])
|
|
318
348
|
when 'gallery'
|
|
319
|
-
field
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
field.value = (sorted + (all_ids - sorted)).to_json
|
|
349
|
+
assign_media_field(field, Array(field_data[:media_ids]))
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def assign_scalar_field_value(field, field_type, field_data)
|
|
354
|
+
case field_type
|
|
326
355
|
when 'multiselect'
|
|
327
356
|
field.value = Array(field_data[:value]).reject(&:blank?).to_json
|
|
328
357
|
else
|
|
@@ -331,58 +360,13 @@ module LatoCms
|
|
|
331
360
|
end
|
|
332
361
|
end
|
|
333
362
|
|
|
334
|
-
#
|
|
335
|
-
#
|
|
336
|
-
#
|
|
337
|
-
def
|
|
338
|
-
Array(
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
def attach_field_files(field, field_data)
|
|
342
|
-
submitted_files(field_data).each { |file| field.files.attach(file) }
|
|
343
|
-
end
|
|
344
|
-
|
|
345
|
-
def assign_file_field_files(field, field_data)
|
|
346
|
-
new_file = submitted_files(field_data).first
|
|
347
|
-
if field.field_settings['multiple'] != true && new_file
|
|
348
|
-
field.files.each(&:purge)
|
|
349
|
-
field.files.attach(new_file)
|
|
350
|
-
else
|
|
351
|
-
attach_field_files(field, field_data)
|
|
352
|
-
remove_field_files(field, field_data)
|
|
353
|
-
end
|
|
354
|
-
end
|
|
355
|
-
|
|
356
|
-
def remove_field_files(field, field_data)
|
|
357
|
-
return unless field_data[:remove_file_ids].present?
|
|
358
|
-
|
|
359
|
-
Array(field_data[:remove_file_ids]).reject(&:blank?).each do |file_id_to_remove|
|
|
360
|
-
field.files.find { |file| file.id == file_id_to_remove.to_i }&.purge
|
|
361
|
-
end
|
|
362
|
-
end
|
|
363
|
-
|
|
364
|
-
def replace_field_image(field, field_data)
|
|
365
|
-
new_file = submitted_files(field_data).first
|
|
366
|
-
if new_file
|
|
367
|
-
field.files.each(&:purge)
|
|
368
|
-
field.files.attach(new_file)
|
|
369
|
-
else
|
|
370
|
-
remove_field_files(field, field_data)
|
|
371
|
-
end
|
|
372
|
-
end
|
|
373
|
-
|
|
374
|
-
# Single-video semantics: uploading a new video replaces both the previous
|
|
375
|
-
# video and its auto-generated poster; removing the video drops the poster
|
|
376
|
-
# too. Poster generation runs in a background job to keep the save fast.
|
|
377
|
-
def replace_field_video(field, field_data)
|
|
378
|
-
new_file = submitted_files(field_data).first
|
|
379
|
-
if new_file
|
|
380
|
-
field.files.each(&:purge)
|
|
381
|
-
field.files.attach(new_file)
|
|
382
|
-
LatoCms::GenerateVideoPosterJob.perform_later(field.id)
|
|
383
|
-
elsif field_data[:remove_file_ids].present?
|
|
384
|
-
field.files.each(&:purge)
|
|
385
|
-
end
|
|
363
|
+
# Tenant-scopes the submitted media ids (drops any id outside this
|
|
364
|
+
# session's spaces group) before delegating to the model, preserving the
|
|
365
|
+
# order given by the client.
|
|
366
|
+
def assign_media_field(field, media_ids)
|
|
367
|
+
ordered_ids = Array(media_ids).reject(&:blank?).map(&:to_s).uniq
|
|
368
|
+
allowed_ids = query_media.where(id: ordered_ids).pluck(:id).map(&:to_s)
|
|
369
|
+
field.replace_media!(ordered_ids & allowed_ids)
|
|
386
370
|
end
|
|
387
371
|
|
|
388
372
|
def create_params
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module LatoCms
|
|
2
|
+
module MediaHelper
|
|
3
|
+
MEDIA_TYPE_ICONS = {
|
|
4
|
+
'image' => 'bi-image',
|
|
5
|
+
'video' => 'bi-film',
|
|
6
|
+
'document' => 'bi-file-earmark-text',
|
|
7
|
+
'file' => 'bi-paperclip'
|
|
8
|
+
}.freeze
|
|
9
|
+
|
|
10
|
+
# Index name cell: thumbnail (or type icon) + name + filename.
|
|
11
|
+
def lato_cms_media_name(media)
|
|
12
|
+
content_tag(:div, class: 'd-flex align-items-center gap-2') do
|
|
13
|
+
concat lato_cms_media_thumb(media)
|
|
14
|
+
concat(content_tag(:div) do
|
|
15
|
+
concat content_tag(:div, media.name)
|
|
16
|
+
concat content_tag(:span, media.filename, class: 'text-muted small')
|
|
17
|
+
end)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Small thumbnail (image variant) or a type icon when there's no visual preview.
|
|
22
|
+
def lato_cms_media_thumb(media, size: 40)
|
|
23
|
+
if media.thumbnail_url
|
|
24
|
+
image_tag media.thumbnail_url, class: 'rounded', style: "width: #{size}px; height: #{size}px; object-fit: cover;", alt: media.alt_text
|
|
25
|
+
else
|
|
26
|
+
content_tag(:div, class: 'd-flex align-items-center justify-content-center bg-light rounded text-muted',
|
|
27
|
+
style: "width: #{size}px; height: #{size}px;") do
|
|
28
|
+
content_tag(:i, '', class: "bi #{MEDIA_TYPE_ICONS[media.media_type] || 'bi-file-earmark'}")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def lato_cms_media_media_type(media)
|
|
34
|
+
content_tag(:span, media.media_type, class: 'badge bg-secondary')
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Index actions cell: edit metadata + delete (delete gated to admins, same
|
|
38
|
+
# convention as pages).
|
|
39
|
+
def lato_cms_media_actions(media)
|
|
40
|
+
content_tag(:div, class: 'btn-group btn-group-sm') do
|
|
41
|
+
concat link_to(t('lato_cms.cta_edit'), lato_cms.media_update_path(media), class: 'btn btn-secondary',
|
|
42
|
+
data: { lato_action_target: 'trigger', turbo_frame: dom_id(media, 'form'), action_title: t('lato_cms.media_update_title') })
|
|
43
|
+
if lato_cms_admin?
|
|
44
|
+
concat link_to(t('lato_cms.cta_delete'), lato_cms.media_destroy_action_path(media), class: 'btn btn-danger',
|
|
45
|
+
data: { turbo_method: 'DELETE', turbo_confirm: t('lato_cms.cta_delete_confirm') })
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require 'csv'
|
|
2
|
+
|
|
3
|
+
module LatoCms
|
|
4
|
+
# Exports every page across every locale of a spaces group as a CSV sitemap.
|
|
5
|
+
# Runs as a Lato::Operation so large sites don't block the request.
|
|
6
|
+
class ExportSitemapCsvJob < Lato::ApplicationJob
|
|
7
|
+
def perform(params = {})
|
|
8
|
+
params = params.with_indifferent_access
|
|
9
|
+
pages = LatoCms::Page.for_lato_spaces_group(params[:lato_spaces_group_id]).order(:locale, :permalink)
|
|
10
|
+
|
|
11
|
+
file_path = Rails.root.join('tmp', "sitemap_#{SecureRandom.hex(8)}.csv")
|
|
12
|
+
generate_csv(file_path, pages)
|
|
13
|
+
|
|
14
|
+
return file_path unless operation?
|
|
15
|
+
|
|
16
|
+
save_operation_output_file(file_path)
|
|
17
|
+
save_operation_output_message(I18n.t('lato_cms.sitemap_export_message', count: pages.count))
|
|
18
|
+
ensure
|
|
19
|
+
File.delete(file_path) if file_path && File.exist?(file_path)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def generate_csv(file_path, pages)
|
|
25
|
+
CSV.open(file_path, 'wb') do |csv|
|
|
26
|
+
csv << %w[locale title permalink frontend_url]
|
|
27
|
+
pages.find_each do |page|
|
|
28
|
+
csv << [page.locale, page.title, page.permalink, page.frontend_url]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module LatoCms
|
|
2
|
+
# Generates alt text for an image Media via an OpenAI-compatible LLM.
|
|
3
|
+
# Doubles as a plain background job (automatic post-upload call site, see
|
|
4
|
+
# Media#enqueue_alt_text_generation: fire-and-forget, best effort, never
|
|
5
|
+
# blocks the upload) and as a Lato::Operation-backed job (the "Regenerate
|
|
6
|
+
# with AI" admin action, see MediaController#regenerate_alt_text_action:
|
|
7
|
+
# the admin is watching, so failures should surface instead of being
|
|
8
|
+
# swallowed). Inheriting Lato::ApplicationJob makes both work from the
|
|
9
|
+
# same perform: `operation?` is only true when a `_operation_id` was
|
|
10
|
+
# injected by Lato::Operation#start.
|
|
11
|
+
class GenerateAltTextJob < Lato::ApplicationJob
|
|
12
|
+
def perform(params = {})
|
|
13
|
+
params = params.with_indifferent_access
|
|
14
|
+
media = LatoCms::Media.find_by(id: params[:media_id])
|
|
15
|
+
return unless media
|
|
16
|
+
|
|
17
|
+
media.generate_alt_text!(raise_on_error: operation?)
|
|
18
|
+
save_operation_output_message(I18n.t('lato_cms.media_alt_text_regenerated')) if operation?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
module LatoCms
|
|
2
|
-
# Generates the poster image for a video
|
|
3
|
-
# request is not blocked by ffmpeg processing.
|
|
4
|
-
#
|
|
2
|
+
# Generates the poster image for a video Media in the background so the
|
|
3
|
+
# upload request is not blocked by ffmpeg processing. Runs once per Media
|
|
4
|
+
# (not per field usage), since the same video can be referenced by many
|
|
5
|
+
# fields. Failures are swallowed by the model (logged as warnings): a
|
|
6
|
+
# missing poster must never break the video.
|
|
5
7
|
class GenerateVideoPosterJob < ApplicationJob
|
|
6
8
|
queue_as :default
|
|
7
9
|
|
|
8
|
-
def perform(
|
|
9
|
-
LatoCms::
|
|
10
|
+
def perform(media_id)
|
|
11
|
+
LatoCms::Media.find_by(id: media_id)&.generate_video_poster!
|
|
10
12
|
end
|
|
11
13
|
end
|
|
12
14
|
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module LatoCms
|
|
2
|
+
# Translates a just-cloned component's free-text fields via the configured
|
|
3
|
+
# LLM, as a Lato::Operation so a slow/unreachable LLM never blocks the
|
|
4
|
+
# clone request (see PagesController#clone_component_action). The verbatim
|
|
5
|
+
# clone this follows has already happened synchronously and is not undone
|
|
6
|
+
# if this fails.
|
|
7
|
+
class TranslateComponentFieldsJob < Lato::ApplicationJob
|
|
8
|
+
def perform(params = {})
|
|
9
|
+
params = params.with_indifferent_access
|
|
10
|
+
page = LatoCms::Page.find(params[:page_id])
|
|
11
|
+
page.translate_component_fields!(params[:template_component_id])
|
|
12
|
+
|
|
13
|
+
save_operation_output_message(I18n.t('lato_cms.component_clone_translated')) if operation?
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require 'net/http'
|
|
2
|
+
|
|
3
|
+
module LatoCms
|
|
4
|
+
# Thin transport for the configured OpenAI-compatible chat completions
|
|
5
|
+
# endpoint (see LatoCms::Config#llm_configured?). Callers own their own
|
|
6
|
+
# prompt/response-shape concerns (e.g. LatoCms::Media for alt text,
|
|
7
|
+
# LatoCms::PagesController for translating cloned fields); this class only
|
|
8
|
+
# knows how to send `messages` and hand back the response's text content.
|
|
9
|
+
class LlmClient
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
def self.chat(messages:)
|
|
13
|
+
uri = URI.join("#{LatoCms.config.llm_api_url.chomp('/')}/", 'chat/completions')
|
|
14
|
+
|
|
15
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
16
|
+
http.use_ssl = uri.scheme == 'https'
|
|
17
|
+
http.open_timeout = 10
|
|
18
|
+
http.read_timeout = 30
|
|
19
|
+
|
|
20
|
+
request = Net::HTTP::Post.new(uri)
|
|
21
|
+
request['Content-Type'] = 'application/json'
|
|
22
|
+
request['Authorization'] = "Bearer #{LatoCms.config.llm_api_key}"
|
|
23
|
+
request.body = { model: LatoCms.config.llm_model, messages: messages }.to_json
|
|
24
|
+
|
|
25
|
+
response = http.request(request)
|
|
26
|
+
raise Error, "LLM request failed with status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
27
|
+
|
|
28
|
+
JSON.parse(response.body).dig('choices', 0, 'message', 'content')
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
require 'base64'
|
|
2
|
+
|
|
3
|
+
module LatoCms
|
|
4
|
+
class Media < ApplicationRecord
|
|
5
|
+
attr_accessor :actions
|
|
6
|
+
|
|
7
|
+
include LatoSpaces::Associable
|
|
8
|
+
include LatoSpaces::AssociableRequired
|
|
9
|
+
include LatoSpaces::AssociableUnique
|
|
10
|
+
|
|
11
|
+
MEDIA_TYPES = %w[image video document file].freeze
|
|
12
|
+
|
|
13
|
+
# alt_text is stored as a single JSON-serialized {locale => text} hash in
|
|
14
|
+
# the `alt_text` column (kept as one column rather than a translations
|
|
15
|
+
# table since it's the only translatable attribute Media has). `alt_text`
|
|
16
|
+
# itself is overridden below to read/write the current I18n.locale's
|
|
17
|
+
# entry; `alt_text_en`, `alt_text_it`, etc. (one per configured locale)
|
|
18
|
+
# are handled dynamically so forms can render/submit them like any other
|
|
19
|
+
# attribute without predefining a method per locale.
|
|
20
|
+
ALT_TEXT_ACCESSOR = /\Aalt_text_(?<locale>[a-z]{2}(?:_[A-Z]{2})?)(?<setter>=)?\z/
|
|
21
|
+
|
|
22
|
+
has_one_attached :file
|
|
23
|
+
has_one_attached :poster_file
|
|
24
|
+
|
|
25
|
+
has_many :page_field_media, class_name: 'LatoCms::PageFieldMedia', foreign_key: :media_id, dependent: :destroy
|
|
26
|
+
has_many :page_fields, through: :page_field_media, class_name: 'LatoCms::PageField'
|
|
27
|
+
|
|
28
|
+
validates :name, presence: true
|
|
29
|
+
validate :file_attached, on: :create
|
|
30
|
+
|
|
31
|
+
before_validation :default_name_from_filename, on: :create
|
|
32
|
+
before_validation :set_media_type, on: :create
|
|
33
|
+
|
|
34
|
+
after_create_commit :enqueue_poster_generation, if: :video?
|
|
35
|
+
after_create_commit :enqueue_alt_text_generation, if: -> { image? && LatoCms.config.llm_configured? }
|
|
36
|
+
|
|
37
|
+
scope :of_type, ->(type) { where(media_type: type) if type.present? }
|
|
38
|
+
|
|
39
|
+
def self.infer_media_type(content_type)
|
|
40
|
+
content_type = content_type.to_s
|
|
41
|
+
return 'image' if content_type.start_with?('image/')
|
|
42
|
+
return 'video' if content_type.start_with?('video/')
|
|
43
|
+
return 'document' if content_type == 'application/pdf' || content_type.start_with?('application/vnd') || content_type.start_with?('text/')
|
|
44
|
+
|
|
45
|
+
'file'
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.variant_transformation(opts)
|
|
49
|
+
opts = {} unless opts.respond_to?(:[])
|
|
50
|
+
dimensions = [opts['width'] || opts[:width], opts['height'] || opts[:height]]
|
|
51
|
+
return nil if dimensions.compact.empty?
|
|
52
|
+
|
|
53
|
+
case (opts['resize'] || opts[:resize] || 'limit').to_s
|
|
54
|
+
when 'fill' then { resize_to_fill: dimensions }
|
|
55
|
+
when 'fit' then { resize_to_fit: dimensions }
|
|
56
|
+
else { resize_to_limit: dimensions }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def image?
|
|
61
|
+
media_type == 'image'
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def video?
|
|
65
|
+
media_type == 'video'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def usage_count
|
|
69
|
+
page_field_media.count
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def filename
|
|
73
|
+
file.filename.to_s if file.attached?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def alt_text(locale = I18n.locale)
|
|
77
|
+
alt_text_translations[locale.to_s].presence
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def alt_text=(value)
|
|
81
|
+
self.alt_text_translations = alt_text_translations.merge(I18n.locale.to_s => value)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def alt_text_translations
|
|
85
|
+
JSON.parse(self[:alt_text].presence || '{}')
|
|
86
|
+
rescue JSON::ParserError
|
|
87
|
+
{}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def alt_text_translations=(hash)
|
|
91
|
+
self[:alt_text] = hash.stringify_keys.to_json
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def method_missing(name, *args)
|
|
95
|
+
match = ALT_TEXT_ACCESSOR.match(name.to_s)
|
|
96
|
+
return super unless match
|
|
97
|
+
|
|
98
|
+
if match[:setter]
|
|
99
|
+
self.alt_text_translations = alt_text_translations.merge(match[:locale] => args.first)
|
|
100
|
+
else
|
|
101
|
+
alt_text_translations[match[:locale]].presence
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def respond_to_missing?(name, include_private = false)
|
|
106
|
+
ALT_TEXT_ACCESSOR.match?(name.to_s) || super
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def url
|
|
110
|
+
Rails.application.routes.url_helpers.rails_blob_path(file, only_path: true) if file.attached?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def poster_url
|
|
114
|
+
Rails.application.routes.url_helpers.rails_blob_path(poster_file, only_path: true) if poster_file.attached?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Best effort: generates a poster from the video via Active Storage previews
|
|
118
|
+
# (ffmpeg). Any failure is logged, the video keeps working without a poster.
|
|
119
|
+
def generate_video_poster!
|
|
120
|
+
return unless video? && file.attached?
|
|
121
|
+
return if poster_file.attached?
|
|
122
|
+
|
|
123
|
+
unless file.previewable?
|
|
124
|
+
Rails.logger.warn("LatoCms: video preview unavailable (ffmpeg missing?) for media #{id}, skipping poster generation")
|
|
125
|
+
return
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
preview = file.preview(resize_to_limit: [1280, 720]).processed
|
|
129
|
+
preview.image.blob.open do |f|
|
|
130
|
+
poster_file.attach(io: f, filename: "#{file.filename.base}_poster.jpg", content_type: preview.image.blob.content_type)
|
|
131
|
+
end
|
|
132
|
+
rescue StandardError => e
|
|
133
|
+
Rails.logger.warn("LatoCms: failed to generate video poster for media #{id}: #{e.message}")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Asks the configured OpenAI-compatible LLM for alt text in every
|
|
137
|
+
# configured locale and merges it in (existing translations for locales
|
|
138
|
+
# the LLM didn't return, or that it's re-run for, are kept/replaced
|
|
139
|
+
# individually). No-op unless an LLM is configured (see
|
|
140
|
+
# LatoCms::Config#llm_configured?) and this media is an image.
|
|
141
|
+
#
|
|
142
|
+
# Best effort by default (`raise_on_error: false`): any failure is logged
|
|
143
|
+
# and swallowed, since the automatic post-upload call site (see
|
|
144
|
+
# GenerateAltTextJob) must never break the upload over a flaky LLM. The
|
|
145
|
+
# manual "Regenerate with AI" action runs as a Lato::Operation the admin
|
|
146
|
+
# is actively watching, so it passes `raise_on_error: true` to have
|
|
147
|
+
# failures surface there instead of disappearing silently.
|
|
148
|
+
def generate_alt_text!(raise_on_error: false)
|
|
149
|
+
return unless image? && file.attached? && LatoCms.config.llm_configured?
|
|
150
|
+
|
|
151
|
+
translations = fetch_alt_text_translations
|
|
152
|
+
raise 'The LLM returned no usable alt text' if translations.blank?
|
|
153
|
+
|
|
154
|
+
self.alt_text_translations = alt_text_translations.merge(translations)
|
|
155
|
+
save!
|
|
156
|
+
rescue StandardError => e
|
|
157
|
+
Rails.logger.warn("LatoCms: failed to generate alt text for media #{id}: #{e.message}")
|
|
158
|
+
raise if raise_on_error
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Fixed small variant used across admin UI (Media index, picker grid, field
|
|
162
|
+
# preview) regardless of a field's own `settings.sizes` (used only by the
|
|
163
|
+
# public API, see `variant_urls`).
|
|
164
|
+
def thumbnail_url
|
|
165
|
+
return nil unless image? && file.attached? && file.variable?
|
|
166
|
+
|
|
167
|
+
Rails.application.routes.url_helpers.rails_representation_path(
|
|
168
|
+
file.variant(resize_to_fill: [200, 200]), only_path: true
|
|
169
|
+
)
|
|
170
|
+
rescue StandardError => e
|
|
171
|
+
Rails.logger.error("LatoCms: Failed to build thumbnail for media #{id}: #{e.message}")
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Builds a map of { size_name => variant_url } from a field's `settings.sizes`
|
|
176
|
+
# config. The config is field-owned (different fields can request different
|
|
177
|
+
# crops of the same reused Media); the mechanics live here since Media owns
|
|
178
|
+
# the attached blob.
|
|
179
|
+
def variant_urls(sizes_config)
|
|
180
|
+
return {} if sizes_config.blank? || !sizes_config.respond_to?(:each_pair) || !file.attached? || !file.variable?
|
|
181
|
+
|
|
182
|
+
url_helpers = Rails.application.routes.url_helpers
|
|
183
|
+
sizes_config.each_with_object({}) do |(name, opts), acc|
|
|
184
|
+
transformation = self.class.variant_transformation(opts)
|
|
185
|
+
next if transformation.blank?
|
|
186
|
+
|
|
187
|
+
acc[name] = url_helpers.rails_representation_path(file.variant(transformation), only_path: true)
|
|
188
|
+
end
|
|
189
|
+
rescue StandardError => e
|
|
190
|
+
Rails.logger.error("LatoCms: Failed to build image variants for media #{id}: #{e.message}")
|
|
191
|
+
{}
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def as_json(_options = {})
|
|
195
|
+
{
|
|
196
|
+
id: id,
|
|
197
|
+
name: name,
|
|
198
|
+
alt_text: alt_text,
|
|
199
|
+
alt_text_translations: alt_text_translations,
|
|
200
|
+
media_type: media_type,
|
|
201
|
+
filename: filename,
|
|
202
|
+
content_type: file.attached? ? file.content_type : nil,
|
|
203
|
+
byte_size: file.attached? ? file.byte_size : nil,
|
|
204
|
+
url: url,
|
|
205
|
+
thumbnail_url: thumbnail_url,
|
|
206
|
+
poster_url: poster_url,
|
|
207
|
+
usage_count: usage_count,
|
|
208
|
+
created_at: created_at,
|
|
209
|
+
updated_at: updated_at
|
|
210
|
+
}
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
private
|
|
214
|
+
|
|
215
|
+
def file_attached
|
|
216
|
+
errors.add(:file, :blank) unless file.attached?
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def default_name_from_filename
|
|
220
|
+
self.name = file.filename.to_s if name.blank? && file.attached?
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def set_media_type
|
|
224
|
+
self.media_type = self.class.infer_media_type(file.content_type) if file.attached?
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def enqueue_poster_generation
|
|
228
|
+
LatoCms::GenerateVideoPosterJob.perform_later(id)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def enqueue_alt_text_generation
|
|
232
|
+
LatoCms::GenerateAltTextJob.perform_later(media_id: id)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def fetch_alt_text_translations
|
|
236
|
+
locales = LatoCms.config.locales.map(&:to_s)
|
|
237
|
+
content = request_alt_text_completion(locales)
|
|
238
|
+
parse_alt_text_response(content, locales)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Sends the image as a base64 data URI rather than a URL: the file may be
|
|
242
|
+
# stored on a service (or behind auth) the LLM can't reach, so this works
|
|
243
|
+
# regardless of storage backend or app visibility settings.
|
|
244
|
+
def request_alt_text_completion(locales)
|
|
245
|
+
LatoCms::LlmClient.chat(messages: [{
|
|
246
|
+
role: 'user',
|
|
247
|
+
content: [
|
|
248
|
+
{ type: 'text', text: alt_text_prompt(locales) },
|
|
249
|
+
{ type: 'image_url', image_url: { url: "data:#{file.content_type};base64,#{Base64.strict_encode64(file.download)}" } }
|
|
250
|
+
]
|
|
251
|
+
}])
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def alt_text_prompt(locales)
|
|
255
|
+
"Write a concise, descriptive alt text for this image, for the HTML <img alt> attribute " \
|
|
256
|
+
"(accessibility use, not a caption). Respond with a single JSON object only, no markdown, " \
|
|
257
|
+
"no extra text, with exactly these keys: #{locales.join(', ')}. Each value is the alt text " \
|
|
258
|
+
"written in that language."
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def parse_alt_text_response(content, locales)
|
|
262
|
+
return {} if content.blank?
|
|
263
|
+
|
|
264
|
+
parsed = JSON.parse(content[/\{.*\}/m] || content)
|
|
265
|
+
parsed.slice(*locales).transform_values(&:to_s)
|
|
266
|
+
rescue JSON::ParserError
|
|
267
|
+
{}
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|