Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Added

- **`Folio::File#used_in_published_content?`** — live check whether a file is used in at least one published piece of content (unwraps atoms to the parent record, respects owner `published?` incl. `published_at`). Intended for host apps that derive public visibility of standalone file pages from usage.
- **Console file usage list** now shows orphaned placements (owner deleted) with the option to remove them, plus a published/unpublished badge for each usage. The usage section is expanded when deletion is blocked.
- **`folio:files:recalculate_placement_counts`** and **`folio:files:report_videos_without_published_usage`** rake tasks.

### Fixed

- **Console remote selects**: Match Select2 arrow and fade overlays to the disabled selection background so long values no longer show white patches.
- **Input character counter**: Count exact plain-text length including repeated internal spaces and trailing spaces.
- **Console validation box Tiptap focus**: Focus the visible Tiptap editor after scrolling to invalid Tiptap content and skip the hidden-input danger blink.
- **AI current form snapshots**: Keep full atom payloads under `record_class.atom_keys` instead of only atom `data` leaves, so host apps using direct atom attributes can build prompt context from unsaved atom-backed forms.
- **Console publishable inputs**: Treat open-ended `Folio::Publishable::Within` date ranges as restricted when the present start or end date excludes the current time, so future `published_from` values no longer render as active when `published_until` is blank.
- A stale `file_placements_count` no longer blocks file deletion — the model destroy guard, console file detail and API destroy verify usage with a live query (`Folio::File#live_indestructible_reason`). File list views still use the cheap counter.
- FriendlyId slug history cleanup and CRA media cleanup on file destroy are now covered by tests (both behaviors already existed).

## [7.7.0] - 2026-06-02

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,40 @@ def before_render
@pagy, @file_placements = pagy(@file.file_placements.includes(:placement).order(created_at: :desc), items: 20)
end

def rows
@file_placements.map do |file_placement|
owner = unwrap_owner(file_placement.placement)

{
file_placement:,
owner:,
orphaned: owner.nil?,
published: owner_published(owner),
action: owner ? placement_action(owner) : { type: :none },
}
end
end

def unwrap_owner(owner)
owner.is_a?(Folio::Atom::Base) ? owner.placement : owner
end

# true / false / nil (nil = owner has no published concept or is orphaned)
def owner_published(owner)
return nil if owner.nil? || !owner.respond_to?(:published?)
owner.published?
end

def orphaned_label(file_placement)
file_placement.placement_title.presence || "##{file_placement.id}"
end

def orphaned_type(file_placement)
file_placement.placement_title_type.presence&.safe_constantize&.model_name&.human ||
file_placement.type.safe_constantize&.model_name&.human ||
file_placement.type
end

def placement_label(placement)
placement.try(:to_label) || "##{placement.id}"
end
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
.f-c-files-show-file-placements
&__row
&--orphaned
color: $text-muted

&__td
vertical-align: middle

Expand Down
107 changes: 70 additions & 37 deletions app/components/folio/console/files/show/file_placements_component.slim
Original file line number Diff line number Diff line change
Expand Up @@ -8,46 +8,79 @@
th.f-c-files-show-file-placements__td = t('.th/type')
th.f-c-files-show-file-placements__td = t('.th/placement_type')
th.f-c-files-show-file-placements__td = t('.th/placement_label')
th.f-c-files-show-file-placements__td = t('.th/published')
th.f-c-files-show-file-placements__td = t('.th/created_at')
th.f-c-files-show-file-placements__td

- @file_placements.each do |file_placement|
- next if file_placement.placement.blank?
- placement = file_placement.placement.is_a?(Folio::Atom::Base) ? file_placement.placement.placement : file_placement.placement
- action = placement_action(placement)

tr.small
td.f-c-files-show-file-placements__td
= placement.model_name.human

- if file_placement.placement.is_a?(Folio::Atom::Base)
.text-muted
' (#{t('.in_atom')})

td.f-c-files-show-file-placements__td
= placement.model_name.human

td.f-c-files-show-file-placements__td
- if action[:url]
a href=action[:url] target="_href"
= placement_label(placement)
- else
= placement_label(placement)

td.f-c-files-show-file-placements__td
= l(file_placement.created_at, format: :console_short)

td.f-c-files-show-file-placements__td
- if action[:url]
a.f-c-files-show-file-placements__action[
href=action[:url]
target="_blank"
class="text-reset"
]
- if action[:type] == :show
= folio_icon(:eye, height: 16)
- else
= folio_icon(:edit_box, height: 16)
- rows.each do |row|
- file_placement = row[:file_placement]

- if row[:orphaned]
tr.small.f-c-files-show-file-placements__row.f-c-files-show-file-placements__row--orphaned
td.f-c-files-show-file-placements__td
= t('.orphaned')

td.f-c-files-show-file-placements__td
= orphaned_type(file_placement)

td.f-c-files-show-file-placements__td
= orphaned_label(file_placement)

td.f-c-files-show-file-placements__td
span.text-muted —

td.f-c-files-show-file-placements__td
= l(file_placement.created_at, format: :console_short)

td.f-c-files-show-file-placements__td.f-c-files-show-file-placements__destroy-cell
= link_to t('.destroy_orphan'),
controller.folio.console_file_placement_path(file_placement),
class: "btn btn-sm btn-danger",
data: { method: :delete, confirm: t('.destroy_orphan_confirm') }
- else
- owner = row[:owner]
- action = row[:action]

tr.small.f-c-files-show-file-placements__row
td.f-c-files-show-file-placements__td
= owner.model_name.human

- if file_placement.placement.is_a?(Folio::Atom::Base)
.text-muted
' (#{t('.in_atom')})

td.f-c-files-show-file-placements__td
= owner.model_name.human

td.f-c-files-show-file-placements__td
- if action[:url]
a href=action[:url] target="_href"
= placement_label(owner)
- else
= placement_label(owner)

td.f-c-files-show-file-placements__td
- if row[:published] == true
span.text-success = t('.published')
- elsif row[:published] == false
span.text-danger = t('.unpublished')
- else
span.text-muted —

td.f-c-files-show-file-placements__td
= l(file_placement.created_at, format: :console_short)

td.f-c-files-show-file-placements__td
- if action[:url]
a.f-c-files-show-file-placements__action[
href=action[:url]
target="_blank"
class="text-reset"
]
- if action[:type] == :show
= folio_icon(:eye, height: 16)
- else
= folio_icon(:edit_box, height: 16)

- if @pagy && @pagy.pages > 1
= render(Folio::Console::Ui::PagyComponent.new(pagy: @pagy))
7 changes: 6 additions & 1 deletion app/components/folio/console/files/show_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,19 @@ def download_button_model
}
end

def live_indestructible_reason
return @live_indestructible_reason if defined?(@live_indestructible_reason)
@live_indestructible_reason = @file.live_indestructible_reason
end

def destroy_button_model
h = {
label: t(".destroy"),
icon: :delete,
variant: :danger,
}

if @file.indestructible_reason
if live_indestructible_reason
h[:disabled] = true
else
h[:data] = stimulus_action({ click: "onDestroyClick" }, { url: url_for([:console, :api, @file]) })
Expand Down
6 changes: 3 additions & 3 deletions app/components/folio/console/files/show_component.slim
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
= render(Folio::Console::Ui::ButtonComponent.new(**replace_button_model))

- if can_now?(:destroy, @file)
- if @file.indestructible_reason
.f-c-files-show__button-tooltip data=stimulus_tooltip(@file.indestructible_reason)
- if live_indestructible_reason
.f-c-files-show__button-tooltip data=stimulus_tooltip(live_indestructible_reason)
= render(Folio::Console::Ui::ButtonComponent.new(**destroy_button_model))
- else
= render(Folio::Console::Ui::ButtonComponent.new(**destroy_button_model))
Expand Down Expand Up @@ -96,7 +96,7 @@
= render(Folio::Console::Files::Show::MetadataComponent.new(file: @file))

= render(Folio::Console::Ui::CollapsibleComponent.new(title: t('.title/usage', count: @file.file_placements_count),
collapsed: controller.action_name != "file_placements"))
collapsed: controller.action_name != "file_placements" && live_indestructible_reason.nil?))
= render(Folio::Console::Files::Show::FilePlacementsComponent.new(file: @file))

- if @file.class.human_type == "image"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,7 @@ def update
end

def destroy
indestructible_reason = folio_console_record.indestructible_reason
indestructible_reason ||= if folio_console_record.file_placements.exists?
I18n.t("folio.file.cannot_destroy_file_with_placements")
end

if indestructible_reason
if (indestructible_reason = folio_console_record.live_indestructible_reason)
render json: { errors: [
status: 422,
title: "ActiveRecord::RecordNotDestroyed",
Expand Down
17 changes: 17 additions & 0 deletions app/controllers/folio/console/file_placements_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

class Folio::Console::FilePlacementsController < Folio::Console::BaseController
def destroy
placement = Folio::FilePlacement::Base.find(params[:id])
authorize!(:destroy, placement.file)

# only orphaned usage records may be removed from here - live content is
# unlinked by editing the owning record
raise ActiveRecord::RecordNotFound if placement.placement.present?

placement.destroy!

redirect_back fallback_location: url_for([:console, placement.file]),
flash: { success: t("folio.console.file_placements.destroyed") }
end
end
38 changes: 36 additions & 2 deletions app/models/folio/file.rb
Original file line number Diff line number Diff line change
Expand Up @@ -348,12 +348,25 @@ def screenshot_time_in_ffmpeg_format
"#{hours.to_s.rjust(2, '0')}:#{minutes.to_s.rjust(2, '0')}:#{seconds.to_s.rjust(2, '0')}.#{centiseconds.to_s.rjust(2, '0')}"
end

# Counter-cache based — cheap, called per row in file lists; may be stale.
# Overridable in host apps. Destroy paths use #live_indestructible_reason.
def indestructible_reason
return nil if file_placements_count == 0
return nil if file_placements_count.nil?
I18n.t("folio.file.cannot_destroy_file_with_placements")
end

# Accurate variant for destroy paths and the file detail — re-verifies the
# placements message with a live query so a stale counter cannot block
# deletion. Host-app reasons other than the placements message pass through.
def live_indestructible_reason
reason = indestructible_reason
placements_message = I18n.t("folio.file.cannot_destroy_file_with_placements")
return reason if reason.present? && reason != placements_message

placements_message if file_placements.exists?
end

def file_list_count
file_placements_count
end
Expand Down Expand Up @@ -414,6 +427,28 @@ def console_show_additional_preview_component
# to be overriden in main_app should be needed
end

# Live visibility check: true if the file is used in at least one published
# piece of content. Unlike +published_usage_count+ (licensing usage limits,
# see #calculate_published_usage_count) this unwraps atoms to their parent
# record and uses the owner's #published? (incl. published_at semantics).
# Atom parents are loaded one query each — the includes(:placement) below
# does not cover the second hop.
# Single-record use only — for collections build an SQL scope instead.
def used_in_published_content?
file_placements.includes(:placement).find_each(batch_size: 100) do |file_placement|
owner = file_placement.placement
owner = owner.placement if owner.is_a?(Folio::Atom::Base)
next if owner.nil?
return true if !owner.respond_to?(:published?) || owner.published?
end

false
end

# NOTE: intentionally different semantics from #used_in_published_content?
# (visibility): here atom-owned placements count as published and only the
# raw `published` column is checked. Consumed by licensing usage limits
# (Folio::File::HasUsageConstraints) — do not change without checking those.
def calculate_published_usage_count
placement_types = file_placements.distinct.pluck(:placement_type)
return 0 if placement_types.empty?
Expand Down Expand Up @@ -479,8 +514,7 @@ def set_file_name_for_search
end

def check_usage_before_destroy
throw(:abort) if indestructible_reason.present?
throw(:abort) if file_placements.exists?
throw(:abort) if live_indestructible_reason.present?
end

def set_file_track_duration
Expand Down
2 changes: 2 additions & 0 deletions config/locales/console/file_placements.cs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ cs:
folio:
console:
file_placements:
destroyed: Záznam použití byl odstraněn.

multi_picker_fields_component:
add_embed: Přidat Embed (HTML kód)
select/image: Vybrat obrázek
Expand Down
2 changes: 2 additions & 0 deletions config/locales/console/file_placements.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ en:
folio:
console:
file_placements:
destroyed: Usage record removed.

multi_picker_fields_component:
add_embed: Add Embed (HTML code)
select/image: Select image
Expand Down
6 changes: 6 additions & 0 deletions config/locales/console/files.cs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,14 @@ cs:
th/type: Typ použití
th/placement_type: Typ záznamu
th/placement_label: Záznam
th/published: Stav
th/created_at: Vytvořeno
in_atom: kapitola obsahu
orphaned: Smazaný obsah
published: Publikováno
unpublished: Nepublikováno
destroy_orphan: Odstranit záznam
destroy_orphan_confirm: Opravdu odstranit osiřelý záznam použití?

thumbnails_component:
title: Verze a velikosti
Expand Down
6 changes: 6 additions & 0 deletions config/locales/console/files.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,14 @@ en:
th/type: Usage type
th/placement_type: Record type
th/placement_label: Record
th/published: State
th/created_at: Created at
in_atom: content chapter
orphaned: Deleted content
published: Published
unpublished: Unpublished
destroy_orphan: Remove record
destroy_orphan_confirm: Really remove the orphaned usage record?
no_thumbnails_yet: No versions generated yet

thumbnails_component:
Expand Down
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@
end
end

resources :file_placements, only: %i[destroy]

if ::Rails.application.config.folio_leads_from_component_class_name
resources :leads, only: %i[index show edit update destroy] do
collection { post :mass_handle }
Expand Down
Loading