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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_field_controller.js +143 -0
  3. data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_picker_controller.js +110 -0
  4. data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_reorder_controller.js +37 -0
  5. data/app/assets/javascripts/lato_cms/controllers/lato_cms_reload_on_modal_close_controller.js +44 -0
  6. data/app/assets/stylesheets/lato_cms/application.scss +131 -158
  7. data/app/controllers/lato_cms/application_controller.rb +4 -0
  8. data/app/controllers/lato_cms/media_controller.rb +121 -0
  9. data/app/controllers/lato_cms/pages_controller.rb +75 -91
  10. data/app/helpers/lato_cms/media_helper.rb +50 -0
  11. data/app/jobs/lato_cms/export_sitemap_csv_job.rb +33 -0
  12. data/app/jobs/lato_cms/generate_alt_text_job.rb +21 -0
  13. data/app/jobs/lato_cms/generate_video_poster_job.rb +7 -5
  14. data/app/jobs/lato_cms/translate_component_fields_job.rb +16 -0
  15. data/app/models/lato_cms/llm_client.rb +31 -0
  16. data/app/models/lato_cms/media.rb +270 -0
  17. data/app/models/lato_cms/page.rb +40 -0
  18. data/app/models/lato_cms/page_field.rb +52 -80
  19. data/app/models/lato_cms/page_field_media.rb +8 -0
  20. data/app/views/lato_cms/media/_form_create.html.erb +20 -0
  21. data/app/views/lato_cms/media/_form_update.html.erb +60 -0
  22. data/app/views/lato_cms/media/create.html.erb +13 -0
  23. data/app/views/lato_cms/media/index.html.erb +22 -0
  24. data/app/views/lato_cms/media/picker_action.html.erb +98 -0
  25. data/app/views/lato_cms/media/update.html.erb +13 -0
  26. data/app/views/lato_cms/pages/_component_accordion.html.erb +15 -0
  27. data/app/views/lato_cms/pages/fields/_file.html.erb +4 -55
  28. data/app/views/lato_cms/pages/fields/_gallery.html.erb +4 -60
  29. data/app/views/lato_cms/pages/fields/_image.html.erb +4 -68
  30. data/app/views/lato_cms/pages/fields/_media_field.html.erb +62 -0
  31. data/app/views/lato_cms/pages/fields/_media_item.html.erb +27 -0
  32. data/app/views/lato_cms/pages/fields/_video.html.erb +4 -69
  33. data/app/views/lato_cms/pages/index.html.erb +17 -0
  34. data/app/views/lato_cms/pages/show.html.erb +2 -1
  35. data/config/locales/en.yml +36 -12
  36. data/config/locales/it.yml +36 -12
  37. data/config/routes.rb +12 -0
  38. data/db/migrate/20260728175413_create_lato_cms_media.rb +11 -0
  39. data/db/migrate/20260728175414_create_lato_cms_page_field_media.rb +12 -0
  40. data/db/migrate/20260729120000_change_lato_cms_media_alt_text_to_translations.rb +25 -0
  41. data/lib/lato_cms/config.rb +12 -1
  42. data/lib/lato_cms/version.rb +1 -1
  43. metadata +38 -5
  44. data/app/assets/javascripts/lato_cms/controllers/lato_cms_file_field_controller.js +0 -225
  45. data/app/assets/javascripts/lato_cms/controllers/lato_cms_gallery_field_controller.js +0 -190
  46. data/app/assets/javascripts/lato_cms/controllers/lato_cms_image_field_controller.js +0 -169
  47. 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
- respond_to do |format|
148
- if source && clone_component_fields(source, @page, template_component_id)
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. Attachments share the source blobs.
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.files.each { |file| field.files.attach(file.blob) }
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
- assign_field_value(field, field_type, field_data)
292
-
293
- unless field.save
294
- errors << { field_id: persisted_field_id, errors: field.errors.full_messages }
295
- return
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
- # Attachments are attached/purged here in the controller, so the model
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 assign_field_value(field, field_type, field_data)
341
+ def assign_media_field_value(field, field_type, field_data)
308
342
  case field_type
309
343
  when 'file'
310
- field.save if field.new_record?
311
- assign_file_field_files(field, field_data)
312
- when 'image'
313
- field.save if field.new_record?
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.save if field.new_record?
320
- attach_field_files(field, field_data)
321
- remove_field_files(field, field_data)
322
- order = Array(field_data[:order]).reject(&:blank?).map(&:to_s)
323
- all_ids = field.files.reload.map { |f| f.id.to_s }
324
- sorted = order.select { |id| all_ids.include?(id) }
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
- # Browsers submit empty entries for file inputs with no selection: strip
335
- # them so `attach` never receives a blank value and the required check
336
- # sees the field as actually empty.
337
- def submitted_files(field_data)
338
- Array(field_data[:files]).reject(&:blank?)
339
- end
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 field in the background so the save
3
- # request is not blocked by ffmpeg processing. Failures are swallowed by the
4
- # model (logged as warnings): a missing poster must never break the field.
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(page_field_id)
9
- LatoCms::PageField.find_by(id: page_field_id)&.generate_video_poster!
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