inline_forms 8.1.47 → 8.1.55

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -0
  3. data/CHANGELOG.md +178 -0
  4. data/Gemfile +3 -0
  5. data/README.rdoc +47 -0
  6. data/app/assets/stylesheets/inline_forms/inline_forms.scss +34 -0
  7. data/app/controllers/concerns/file_slots_concern.rb +211 -0
  8. data/app/controllers/inline_forms/file_trash_controller.rb +115 -0
  9. data/app/controllers/inline_forms_controller.rb +82 -16
  10. data/app/helpers/inline_forms_file_trash_helper.rb +131 -0
  11. data/app/helpers/inline_forms_helper.rb +5 -1
  12. data/app/models/concerns/inline_forms/stored_files.rb +350 -0
  13. data/app/models/inline_forms/current.rb +8 -0
  14. data/app/models/inline_forms/file_trash_sweep.rb +37 -0
  15. data/app/models/inline_forms/trashed_file.rb +205 -0
  16. data/app/views/devise/passwords/edit.html.erb +1 -1
  17. data/app/views/devise/passwords/new.html.erb +1 -1
  18. data/app/views/devise/shared/_header_and_errors.html.erb +9 -2
  19. data/app/views/inline_forms/_file_slot.html.erb +39 -0
  20. data/app/views/inline_forms/_header.html.erb +3 -0
  21. data/app/views/inline_forms/_model_top_bar.html.erb +3 -4
  22. data/app/views/inline_forms/_show.html.erb +6 -0
  23. data/app/views/inline_forms/_versions.html.erb +4 -4
  24. data/app/views/inline_forms/_versions_file_entry.html.erb +36 -0
  25. data/app/views/inline_forms/_versions_list.html.erb +11 -2
  26. data/app/views/inline_forms/file_field_frame.html.erb +22 -0
  27. data/app/views/inline_forms/file_trash/index.html.erb +112 -0
  28. data/app/views/inline_forms/file_trash_panel.html.erb +90 -0
  29. data/app/views/layouts/inline_forms.html.erb +5 -1
  30. data/lib/generators/inline_forms/file_trash_generator.rb +36 -0
  31. data/lib/generators/inline_forms/templates/create_inline_forms_file_trash.rb.erb +41 -0
  32. data/lib/generators/inline_forms_addto_generator.rb +47 -0
  33. data/lib/generators/inline_forms_generator.rb +19 -2
  34. data/lib/generators/templates/model.erb +4 -0
  35. data/lib/inline_forms/form_element_from_callee.rb +25 -0
  36. data/lib/inline_forms/form_elements/simple_file_field_helper.rb +30 -2
  37. data/lib/inline_forms/gem_files.rb +37 -0
  38. data/lib/inline_forms/version.rb +1 -1
  39. data/lib/inline_forms.rb +55 -0
  40. data/lib/locales/inline_forms.en.yml +87 -0
  41. data/lib/locales/inline_forms.nl.yml +87 -3
  42. data/lib/tasks/inline_forms_files.rake +61 -0
  43. data/test/dummy/app/controllers/application_controller.rb +5 -0
  44. data/test/dummy/app/controllers/documents_controller.rb +4 -0
  45. data/test/dummy/app/controllers/dossiers_controller.rb +4 -0
  46. data/test/dummy/app/models/ability.rb +15 -0
  47. data/test/dummy/app/models/document.rb +33 -0
  48. data/test/dummy/app/models/dossier.rb +38 -0
  49. data/test/dummy/config/application.rb +4 -3
  50. data/test/dummy/config/routes.rb +13 -0
  51. data/test/gem_package_modes_test.rb +86 -0
  52. data/test/inline_forms_addto_generator_test.rb +47 -0
  53. data/test/inline_forms_generator_test.rb +24 -0
  54. data/test/integration/field_authorization_test.rb +131 -0
  55. data/test/integration/file_slots_test.rb +574 -0
  56. data/test/integration/layout_title_test.rb +56 -0
  57. data/test/integration/model_top_bar_test.rb +9 -0
  58. data/test/integration/revert_skipped_columns_test.rb +59 -0
  59. data/test/integration_test_helper.rb +62 -0
  60. metadata +36 -3
  61. data/.claude/settings.local.json +0 -9
  62. data/CLAUDE.md +0 -68
@@ -0,0 +1,90 @@
1
+ <%# Per-record trash panel ("Removed files (n)"), beside Versions. Lists the
2
+ trash rows of the slots shown on this page (a Client tab only its own),
3
+ newest first; restored and purged rows stay as greyed history. The frame
4
+ is always rendered, empty or not, so a file action can stream into it.
5
+ Never loads file bytes (without_data). -%>
6
+ <% object = local_assigns[:object] || @object -%>
7
+ <% update_span = local_assigns[:update_span] || @update_span -%>
8
+ <% slots = local_assigns.key?(:slots) ? local_assigns[:slots] : @file_trash_slots -%>
9
+ <% open = local_assigns.key?(:open) ? local_assigns[:open] : @file_trash_open -%>
10
+ <% slots = Array(slots) -%>
11
+ <% entries = slots.empty? ? [] : InlineForms::TrashedFile.without_data.trash_entries.for_record(object).where(attribute_name: slots).newest_first.to_a -%>
12
+ <% slots_param = slots.join(",") -%>
13
+ <turbo-frame id="<%= update_span %>">
14
+ <% if entries.any? %>
15
+ <% in_trash = entries.count(&:trashed?) -%>
16
+ <% toggle_path = polymorphic_path(object, action: :file_trash, update: update_span, slots: slots_param, close: (open ? true : nil)) -%>
17
+ <div class="row form_element_header associated_auto_header callout inline_forms-file-trash">
18
+ <div class='medium-11 large-11 column' >
19
+ <%= t("inline_forms.files.trash_header", count: in_trash) %>
20
+ </div>
21
+ <div class='medium-1 large-1 column'>
22
+ <%= inline_forms_file_icon_link (open ? "fi-x" : "fi-list"),
23
+ (open ? t("inline_forms.files.close_trash") : t("inline_forms.files.open_trash")),
24
+ toggle_path,
25
+ { class: "button #{open ? 'close_button' : 'new_button'}", data: { turbo: true, turbo_frame: update_span } } %>
26
+ </div>
27
+ </div>
28
+ <% if open %>
29
+ <% if (stale = entries.select(&:expired?).reject { |e| e.purged? || e.restored? }).any? %>
30
+ <div class="row"><div class="small-12 column inline_forms-file-meta"><%= t("inline_forms.files.expired_pending", count: stale.size) %></div></div>
31
+ <% end %>
32
+ <% entries.each do |entry| %>
33
+ <% attribute = entry.attribute_name -%>
34
+ <% field_frame = inline_forms_file_field_frame_id(object, attribute) -%>
35
+ <div class="row <%= cycle('odd', 'even') %> inline_forms-file-trash-row<%= ' inline_forms-file-trash-history' unless entry.trashed? %>">
36
+ <div class="small-12 medium-3 column">
37
+ <strong><%= object.class.human_attribute_name(attribute) %></strong>
38
+ </div>
39
+ <div class="small-12 medium-5 column">
40
+ <span class="inline_forms-file-name"><%= inline_forms_file_entry_name(entry) %></span>
41
+ <span class="inline_forms-file-meta">
42
+ <%= inline_forms_file_size(entry.byte_size) %> &middot;
43
+ <%= t("inline_forms.files.reason.#{entry.reason}") %>
44
+ <%= t("inline_forms.files.by_on", user: inline_forms_file_user_name(entry.trashed_by_id), at: l(entry.trashed_at, format: :short)) %>
45
+ </span>
46
+ <span class="inline_forms-file-meta">
47
+ <% if entry.state == :restored %>
48
+ <%= t("inline_forms.files.state_restored", user: inline_forms_file_user_name(entry.restored_by_id), at: l(entry.restored_at, format: :short)) %>
49
+ <% elsif entry.state == :purged %>
50
+ <%= t("inline_forms.files.state_purged", user: inline_forms_file_user_name(entry.purged_by_id), at: l(entry.purged_at, format: :short)) %>
51
+ <% elsif entry.state == :expired %>
52
+ <%= t("inline_forms.files.state_expired") %>
53
+ <% else %>
54
+ <% if entry.purge_after %>
55
+ <% days_left = ((entry.purge_after - Time.current) / 1.day).ceil -%>
56
+ <span class="<%= 'inline_forms-file-expiring' if days_left <= 7 %>"><%= t("inline_forms.files.expires_in", count: days_left, date: l(entry.purge_after.to_date)) %></span>
57
+ <% end %>
58
+ <% end %>
59
+ </span>
60
+ </div>
61
+ <div class="small-12 medium-4 column inline_forms-file-actions">
62
+ <% if entry.trashed? %>
63
+ <% if inline_forms_file_can?(:download_trashed_file, object, attribute) %>
64
+ <%= link_to t("inline_forms.files.download"),
65
+ polymorphic_path(object, action: :trashed_file, attribute: attribute, trash_id: entry.id),
66
+ data: { turbo: false } %>
67
+ <% end %>
68
+ <% if inline_forms_file_can?(:restore_file, object, attribute) %>
69
+ <% occupied = object.inline_forms_file_present?(attribute) -%>
70
+ <% if inline_forms_file_can?(occupied ? :replace_file : :update, object, attribute) %>
71
+ <% confirm = occupied ? t("inline_forms.files.confirm_restore_swap", current: object[attribute], filename: inline_forms_file_entry_name(entry)) : nil -%>
72
+ <%= link_to t("inline_forms.files.restore"),
73
+ polymorphic_path(object, action: :restore_file, attribute: attribute, trash_id: entry.id,
74
+ update: field_frame, slots: slots_param, panel: "open"),
75
+ inline_forms_file_post_data(update_span, confirm: confirm) %>
76
+ <% end %>
77
+ <% end %>
78
+ <% if inline_forms_file_can?(:purge_file, object, attribute) %>
79
+ <%= link_to t("inline_forms.files.purge"),
80
+ polymorphic_path(object, action: :purge_file, attribute: attribute, trash_id: entry.id,
81
+ update: field_frame, slots: slots_param, panel: "open"),
82
+ inline_forms_file_post_data(update_span, confirm: t("inline_forms.files.confirm_purge", filename: inline_forms_file_entry_name(entry))).deep_merge(class: "alert") %>
83
+ <% end %>
84
+ <% end %>
85
+ </div>
86
+ </div>
87
+ <% end %>
88
+ <% end %>
89
+ <% end %>
90
+ </turbo-frame>
@@ -3,7 +3,11 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title><%= t('application_name') + " v" + InlineForms::VERSION -%></title>
6
+ <%# A missing translation must not reach <title>: a bare t() returns an %>
7
+ <%# html_safe <span class="translation_missing"> that shows up as markup. %>
8
+ <%# The `default:` (the host's ApplicationHelper#application_name, which %>
9
+ <%# the header partial needs anyway) makes t() return plain, escaped text. %>
10
+ <title><%= t('application_name', default: application_name) %> v<%= InlineForms::VERSION %></title>
7
11
  <%= stylesheet_link_tag "application" %>
8
12
  <%= stylesheet_link_tag "inline_forms_fonts" %>
9
13
  <%= stylesheet_link_tag "inline_forms/inline_forms" %>
@@ -0,0 +1,36 @@
1
+ # -*- encoding : utf-8 -*-
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+
6
+ module InlineForms
7
+ module Generators
8
+ # `rails g inline_forms:file_trash`
9
+ #
10
+ # Writes the migration for the file trash of declared file slots
11
+ # (InlineForms::StoredFiles): inline_forms_trashed_files (one row per
12
+ # removed / replaced / uploaded file event, bytes until purged) and
13
+ # inline_forms_file_trash_sweeps (the retention sweep's heartbeat). The
14
+ # installer runs it for new apps; existing apps run it once by hand.
15
+ class FileTrashGenerator < Rails::Generators::Base
16
+ include Rails::Generators::Migration
17
+
18
+ source_root File.expand_path("templates", __dir__)
19
+
20
+ def self.next_migration_number(dirname)
21
+ # Collision-free against migrations generated in the same second
22
+ # (same policy as inline_forms_addto since 8.1.41).
23
+ now = Time.now.utc.strftime("%Y%m%d%H%M%S")
24
+ existing = Dir.glob(File.join(dirname, "*.rb")).map { |f| File.basename(f)[/\A\d+/] }.compact
25
+ [ now, *existing.map(&:to_s) ].max.then do |highest|
26
+ highest == now ? now : (highest.to_i + 1).to_s
27
+ end
28
+ end
29
+
30
+ def create_migration_file
31
+ migration_template "create_inline_forms_file_trash.rb.erb",
32
+ "db/migrate/create_inline_forms_file_trash.rb"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,41 @@
1
+ # File trash for declared file slots (inline_forms, InlineForms::StoredFiles).
2
+ class CreateInlineFormsFileTrash < ActiveRecord::Migration[8.1]
3
+ def change
4
+ # Trashed bytes can be as large as the slot's own column: LONGBLOB on
5
+ # MySQL (a plain BLOB stops at 64 KB); a plain binary elsewhere (SQLite
6
+ # and PostgreSQL have no size variants).
7
+ long_blob = connection.adapter_name.match?(/mysql|trilogy/i) ? { size: :long } : {}
8
+
9
+ create_table :inline_forms_trashed_files do |t|
10
+ t.string :record_type, null: false
11
+ t.bigint :record_id, null: false
12
+ t.string :attribute_name, null: false
13
+ t.string :filename
14
+ t.string :content_type
15
+ t.bigint :byte_size
16
+ t.string :checksum, limit: 64
17
+ t.binary :data, **long_blob
18
+ t.string :reason, null: false
19
+ t.boolean :redacted, null: false, default: false
20
+ t.bigint :trashed_by_id
21
+ t.datetime :trashed_at, null: false
22
+ t.bigint :restored_by_id
23
+ t.datetime :restored_at
24
+ t.bigint :purged_by_id
25
+ t.datetime :purged_at
26
+ t.datetime :purge_after
27
+ t.timestamps
28
+ end
29
+ add_index :inline_forms_trashed_files, [ :record_type, :record_id, :attribute_name ],
30
+ name: "index_inline_forms_trashed_files_on_slot"
31
+ add_index :inline_forms_trashed_files, [ :purged_at, :restored_at, :purge_after ],
32
+ name: "index_inline_forms_trashed_files_on_state"
33
+
34
+ create_table :inline_forms_file_trash_sweeps do |t|
35
+ t.datetime :ran_at, null: false
36
+ t.integer :purged_count, null: false, default: 0
37
+ t.text :error
38
+ t.timestamps
39
+ end
40
+ end
41
+ end
@@ -117,6 +117,12 @@ class InlineFormsAddtoGenerator < Rails::Generators::NamedBase
117
117
 
118
118
  if attribute.column_type == :belongs_to
119
119
  @migration_lines << " add_reference :#{table_name}, :#{attribute.name}, foreign_key: true\n"
120
+ elsif attribute.type == :simple_file_field
121
+ # Declared file slot: filename + bytes (LONGBLOB on MySQL) + content type.
122
+ columns = InlineForms.file_slot_columns(attribute.name)
123
+ @migration_lines << " add_column :#{table_name}, :#{columns[:filename]}, :string\n"
124
+ @migration_lines << " add_column :#{table_name}, :#{columns[:data]}, :binary, **(connection.adapter_name.match?(/mysql|trilogy/i) ? { size: :long } : {})\n"
125
+ @migration_lines << " add_column :#{table_name}, :#{columns[:content_type]}, :string\n"
120
126
  else
121
127
  commenter = attribute.attribute_type == :unknown ? "#" : " "
122
128
  @migration_lines << "#{commenter} add_column :#{table_name}, :#{attribute.name}, :#{attribute.column_type}\n"
@@ -158,6 +164,8 @@ class InlineFormsAddtoGenerator < Rails::Generators::NamedBase
158
164
  inject_class_line!(" has_and_belongs_to_many :#{attribute.name}\n")
159
165
  when :rich_text
160
166
  inject_class_line!(" has_rich_text :#{attribute.name}\n")
167
+ when :simple_file_field
168
+ add_file_slot!(attribute)
161
169
  end
162
170
 
163
171
  next unless attribute.attribute?
@@ -182,6 +190,45 @@ class InlineFormsAddtoGenerator < Rails::Generators::NamedBase
182
190
 
183
191
  private
184
192
 
193
+ # A :simple_file_field is a declared file slot: the model gets
194
+ # InlineForms::StoredFiles + `inline_forms_file :attr`, and the resource
195
+ # block in routes.rb gets InlineForms.file_routes(self) (when it can be
196
+ # found; otherwise a reminder).
197
+ def add_file_slot!(attribute)
198
+ content = File.read(File.join(destination_root, model_file_path))
199
+ include_line = " include InlineForms::StoredFiles\n"
200
+ macro = " inline_forms_file :#{attribute.name}\n"
201
+ return if content.include?(macro)
202
+
203
+ if content.include?(include_line)
204
+ inject_into_file model_file_path, macro, after: include_line
205
+ else
206
+ # One injection: inject_into_class puts each line right under the
207
+ # class line, so two calls would land the macro above the include.
208
+ inject_class_line!(include_line + macro)
209
+ end
210
+ add_file_routes!
211
+ end
212
+
213
+ def add_file_routes!
214
+ routes = "config/routes.rb"
215
+ path = File.join(destination_root, routes)
216
+ return unless File.exist?(path)
217
+
218
+ content = File.read(path)
219
+ opener = content[/^(\s*)resources :#{table_name} do\n/]
220
+ if opener.nil?
221
+ say_status :reminder, "add InlineForms.file_routes(self) inside `resources :#{table_name} do ... end` in #{routes}", :yellow
222
+ return
223
+ end
224
+ block = content[content.index(opener)..][/\A.*?^\s*end$/m]
225
+ return if block&.include?("InlineForms.file_routes")
226
+
227
+ indent = opener[/\A\s*/]
228
+ inject_into_file routes, "#{indent} InlineForms.file_routes(self)\n", after: opener
229
+ end
230
+
231
+
185
232
  def model_file_name
186
233
  name.underscore
187
234
  end
@@ -66,6 +66,7 @@ module InlineForms
66
66
  @order = "\n"
67
67
  @list_scopes = ""
68
68
  @carrierwave_mounters = "\n"
69
+ @stored_files = String.new
69
70
  @inline_forms_attribute_list = String.new
70
71
 
71
72
  for attribute in attributes
@@ -94,6 +95,12 @@ module InlineForms
94
95
  if attribute.type == :rich_text
95
96
  @has_rich_text << ' has_rich_text :' + attribute.name + "\n"
96
97
  end
98
+ # Declared file slot: bytes + content type in the model's own table,
99
+ # replace/remove/undo through the file trash (InlineForms::StoredFiles).
100
+ if attribute.type == :simple_file_field
101
+ @stored_files << " include InlineForms::StoredFiles\n" if @stored_files.empty?
102
+ @stored_files << ' inline_forms_file :' + attribute.name + "\n"
103
+ end
97
104
  if attribute.name == '_presentation'
98
105
  @presentation << " def _presentation\n" +
99
106
  " \"#{attribute.type.to_s}\"\n" +
@@ -142,10 +149,11 @@ module InlineForms
142
149
 
143
150
  def generate_resource_route
144
151
  if @flag_create_resource_route
152
+ file_routes = file_slot_attributes.any? ? "\n InlineForms.file_routes(self)" : ""
145
153
  route <<-ROUTE.strip_heredoc
146
154
  resources :#{resource_name} do
147
155
  post 'revert', :on => :member
148
- get 'list_versions', :on => :member
156
+ get 'list_versions', :on => :member#{file_routes}
149
157
  end
150
158
  ROUTE
151
159
  end
@@ -156,7 +164,12 @@ module InlineForms
156
164
  @columns = String.new
157
165
 
158
166
  for attribute in attributes
159
- if attribute.column_type == :image
167
+ if attribute.type == :simple_file_field
168
+ columns = InlineForms.file_slot_columns(attribute.name)
169
+ @columns << ' t.string :' + columns[:filename] + "\n"
170
+ @columns << ' t.binary :' + columns[:data] + ", **(connection.adapter_name.match?(/mysql|trilogy/i) ? { size: :long } : {})\n"
171
+ @columns << ' t.string :' + columns[:content_type] + "\n"
172
+ elsif attribute.column_type == :image
160
173
  @columns << ' t.string :' + attribute.name + "_file_name\n"
161
174
  @columns << ' t.string :' + attribute.name + "_content_type\n"
162
175
  @columns << ' t.integer :' + attribute.name + "_file_size\n"
@@ -230,6 +243,10 @@ module InlineForms
230
243
 
231
244
 
232
245
  private
246
+ def file_slot_attributes
247
+ attributes.select { |attribute| attribute.type == :simple_file_field }
248
+ end
249
+
233
250
  def model_file_name
234
251
  name.underscore
235
252
  end
@@ -6,6 +6,10 @@ class <%= name %> < ApplicationRecord
6
6
  <%= @habtm if @habtm.length > 1 -%>
7
7
  <%= @has_rich_text if @has_rich_text.length > 1 -%>
8
8
  <%= @has_attached_files if @has_attached_files.length > 1 -%>
9
+ <% unless @stored_files.empty? -%>
10
+
11
+ <%= @stored_files -%>
12
+ <% end -%>
9
13
  <% if @list_scopes && !@list_scopes.empty? -%>
10
14
 
11
15
  <%= @list_scopes -%>
@@ -8,4 +8,29 @@ module InlineForms
8
8
  s = s.sub(/\Ablock in /, "")
9
9
  s.delete_suffix("_show")
10
10
  end
11
+
12
+ # Form elements whose +*_show+ helper delegates to another element's, so
13
+ # their edit link carries the delegate's name (+__callee__+ of the inner
14
+ # helper) as +form_element+ instead of the declared one.
15
+ FORM_ELEMENT_DELEGATES = {
16
+ plain_text_area: :plain_text,
17
+ text_area: :rich_text
18
+ }.freeze
19
+
20
+ # The +inline_forms_attribute_list+ row for a single-field request, or nil
21
+ # when +attribute+/+form_element+ (both from params) do not name a row the
22
+ # UI could have linked to. The controller uses this as a whitelist before
23
+ # it dispatches +send("#{form_element}_update")+ or touches the attribute.
24
+ def self.attribute_list_row_for(attribute_list, attribute, form_element)
25
+ attribute = attribute.to_s
26
+ form_element = form_element.to_s
27
+ return nil if attribute.empty? || form_element.empty?
28
+
29
+ attribute_list.find do |listed_attribute, declared, *|
30
+ next false unless listed_attribute.to_s == attribute
31
+
32
+ declared.to_s == form_element ||
33
+ FORM_ELEMENT_DELEGATES[declared.to_s.to_sym].to_s == form_element
34
+ end
35
+ end
11
36
  end
@@ -5,7 +5,15 @@ module InlineForms
5
5
  module_eval(<<~'INLINE_FORMS_FORM_ELEMENT', __FILE__, __LINE__ + 1)
6
6
  # -*- encoding : utf-8 -*-
7
7
 
8
+ # Two modes. A declared file slot (InlineForms::StoredFiles + routes from
9
+ # InlineForms.file_routes) renders the slot partial: download through the
10
+ # gem, replace / remove / undo icons by permission. Anything else keeps the
11
+ # legacy behaviour below: a link to the host's download route named in the
12
+ # values hash, and an edit link only while the field is empty.
8
13
  def simple_file_field_show(object, attribute)
14
+ if inline_forms_file_slot_active?(object, attribute)
15
+ return render(partial: "inline_forms/file_slot", locals: { object: object, attribute: attribute })
16
+ end
9
17
  o = object.send(attribute)
10
18
  attributes = @inline_forms_attribute_list || object.inline_forms_attribute_list
11
19
  values = attributes.assoc(attribute.to_sym)[2]
@@ -21,11 +29,31 @@ module InlineForms
21
29
  end
22
30
 
23
31
  def simple_file_field_edit(object, attribute)
24
- file_field_tag attribute, :class => 'input_text_field'
32
+ field = file_field_tag attribute, :class => 'input_text_field'
33
+ return field unless object.class.respond_to?(:inline_forms_file_slot?) && object.class.inline_forms_file_slot?(attribute)
34
+
35
+ field = file_field_tag attribute, class: 'input_text_field', required: true
36
+ return field unless object.inline_forms_file_present?(attribute)
37
+
38
+ # Replacing: say which file goes, and where it goes.
39
+ hint = t("inline_forms.files.replace_hint", filename: object[attribute.to_s])
40
+ safe_join([ field, content_tag(:span, hint, class: "inline_forms-file-hint") ])
25
41
  end
26
42
 
27
43
  def simple_file_field_update(object, attribute)
28
- object.send(attribute.to_s + '=', params[attribute.to_sym])
44
+ value = params[attribute.to_sym]
45
+ if object.class.respond_to?(:inline_forms_file_slot?) && object.class.inline_forms_file_slot?(attribute)
46
+ # A declared slot only takes an uploaded file from a request (a crafted
47
+ # string would set a filename without bytes). Nothing chosen is a
48
+ # validation error, not a silent success.
49
+ if value.respond_to?(:original_filename)
50
+ object.send(attribute.to_s + '=', value)
51
+ else
52
+ object.inline_forms_file_missing!(attribute)
53
+ end
54
+ return
55
+ end
56
+ object.send(attribute.to_s + '=', value)
29
57
  end
30
58
 
31
59
  # You need to add a route to your routes.rb file:
@@ -1,5 +1,8 @@
1
1
  # -*- encoding: utf-8 -*-
2
2
 
3
+ require "delegate"
4
+ require "rubygems/package"
5
+
3
6
  module InlineFormsGemFiles
4
7
  INSTALLER_FILE_PREFIXES = %w[
5
8
  bin/inline_forms
@@ -23,8 +26,13 @@ module InlineFormsGemFiles
23
26
  # ignores stuff/ is per-machine (present here, absent on the release box), so
24
27
  # the token leaked into `gem build` on the release machine. Exclude it hard,
25
28
  # independent of any ignore configuration.
29
+ #
30
+ # Same for local Claude Code files (untracked, per checkout): .claude/
31
+ # (launch.json, settings.local.json) and CLAUDE.md.
26
32
  EXCLUDED_FILE_PREFIXES = %w[
27
33
  stuff/
34
+ .claude/
35
+ CLAUDE.md
28
36
  ].freeze
29
37
 
30
38
  module_function
@@ -63,4 +71,33 @@ module InlineFormsGemFiles
63
71
  include_installer ? !installer_file : installer_file
64
72
  end
65
73
  end
74
+
75
+ # Mode a file gets inside the .gem: 0755 if the owner may execute it,
76
+ # else 0644. RubyGems packages each file's on-disk mode (File.lstat) and
77
+ # `gem install` restores it, and this checkout is group-only (rw-rw----),
78
+ # so 8.1.53 shipped every file as 0660: only the installing user and group
79
+ # could read the installed gem, `require "inline_forms"` failed silently
80
+ # for anyone else (Bundler swallows the LoadError), and a Docker image that
81
+ # installs as root and runs as another user could not boot.
82
+ def packaged_mode(mode)
83
+ (mode & 0o100).zero? ? 0o644 : 0o755
84
+ end
85
+
86
+ # Wraps the tar writer Gem::Package#add_files writes to, so every file
87
+ # goes in with packaged_mode instead of its on-disk mode.
88
+ class ReadableTar < SimpleDelegator
89
+ def add_file_simple(name, mode, size, &block)
90
+ __getobj__.add_file_simple(name, InlineFormsGemFiles.packaged_mode(mode), size, &block)
91
+ end
92
+ end
93
+
94
+ module ReadablePackage
95
+ def add_files(tar)
96
+ super(ReadableTar.new(tar))
97
+ end
98
+ end
66
99
  end
100
+
101
+ # Loaded by every gemspec in this repo, so `gem build`, `rake build` and the
102
+ # CI full gate all package world-readable files whatever the checkout's modes.
103
+ Gem::Package.prepend(InlineFormsGemFiles::ReadablePackage) unless Gem::Package <= InlineFormsGemFiles::ReadablePackage
@@ -1,5 +1,5 @@
1
1
  # -*- encoding : utf-8 -*-
2
2
 
3
3
  module InlineForms
4
- VERSION = "8.1.47"
4
+ VERSION = "8.1.55"
5
5
  end
data/lib/inline_forms.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # -*- encoding : utf-8 -*-
2
2
 
3
+ require "active_support/core_ext/module/attribute_accessors"
4
+ require "active_support/core_ext/numeric/time"
3
5
  require "inline_forms/version"
4
6
  require "inline_forms/attribute_list"
5
7
  require "inline_forms/schema_intent"
@@ -18,6 +20,59 @@ require "inline_forms/turbo_tabs_builder"
18
20
  module InlineForms
19
21
  class PlainTextColumnMissingError < StandardError; end
20
22
 
23
+ # ---- File slots and the file trash (InlineForms::StoredFiles) ------------
24
+ #
25
+ # How long a removed/replaced file stays restorable in the trash before the
26
+ # sweep (`rake inline_forms:file_trash:purge_expired`) deletes its bytes.
27
+ # nil = never expire (purge by hand only).
28
+ mattr_accessor :file_trash_retention, default: 30.days
29
+
30
+ # Purging also blanks the stored filename (rendered as "removed file").
31
+ # Off by default: the filename is kept as part of the history.
32
+ mattr_accessor :file_trash_redact_on_purge, default: false
33
+
34
+ # Largest accepted upload for a declared file slot, in bytes; nil = no
35
+ # limit beyond the web server's. Enforced as a validation error.
36
+ mattr_accessor :max_file_size, default: nil
37
+
38
+ # Column names of a :simple_file_field slot, from its attribute: `filename`
39
+ # -> data / content_type; `<base>_filename` and any other `<base>` ->
40
+ # `<base>_data` / `<base>_content_type`. Shared by InlineForms::StoredFiles
41
+ # (its defaults) and the generators (the columns they create).
42
+ def self.file_slot_columns(attribute)
43
+ attribute = attribute.to_s
44
+ base = attribute == "filename" ? nil : attribute.delete_suffix("_filename")
45
+ { filename: attribute, data: base ? "#{base}_data" : "data", content_type: base ? "#{base}_content_type" : "content_type" }
46
+ end
47
+
48
+ # Member routes for a resource whose model declares file slots. Call it
49
+ # inside the resource block (order-independent, unlike a routing concern):
50
+ #
51
+ # resources :clients do
52
+ # post 'revert', on: :member
53
+ # get 'list_versions', on: :member
54
+ # InlineForms.file_routes(self)
55
+ # end
56
+ def self.file_routes(router)
57
+ router.member do
58
+ router.get "download_file"
59
+ router.post "remove_file"
60
+ router.post "restore_file"
61
+ router.post "purge_file"
62
+ router.get "trashed_file"
63
+ router.get "file_trash"
64
+ end
65
+ end
66
+
67
+ # The cross-record trash page (superadmin). One line in routes.rb:
68
+ # InlineForms.draw_file_trash_routes(self)
69
+ def self.draw_file_trash_routes(router)
70
+ router.get "file_trash", to: "inline_forms/file_trash#index", as: :inline_forms_file_trash
71
+ router.post "file_trash/purge", to: "inline_forms/file_trash#purge", as: :inline_forms_file_trash_purge
72
+ router.post "file_trash/:id/restore", to: "inline_forms/file_trash#restore", as: :inline_forms_file_trash_restore
73
+ router.get "file_trash/:id/download", to: "inline_forms/file_trash#download", as: :inline_forms_file_trash_download
74
+ end
75
+
21
76
  # DEFAULT_COLUMN_TYPES holds the standard ActiveRecord::Migration column types.
22
77
  # This list provides compatability with the standard types, but we add our own
23
78
  # later in 'Special Column Types'.
@@ -31,6 +31,89 @@ en:
31
31
  trash: trash
32
32
  list_versions: List versions
33
33
  close_versions_list: Close versions list
34
+ files:
35
+ download: download
36
+ replace: replace file
37
+ remove: remove file
38
+ restore: restore
39
+ restore_file: restore file
40
+ purge: delete permanently
41
+ undo: undo
42
+ restore_previous: "restore removed file %{filename}"
43
+ replace_hint: "The current file %{filename} goes to the trash."
44
+ confirm_remove:
45
+ one: "Remove %{filename}? It goes to the trash and can be restored for 1 day."
46
+ other: "Remove %{filename}? It goes to the trash and can be restored for %{count} days."
47
+ confirm_remove_no_expiry: "Remove %{filename}? It goes to the trash and can be restored."
48
+ confirm_restore_swap: "The current file %{current} goes to the trash and %{filename} is put back. Continue?"
49
+ confirm_purge: "Delete %{filename} permanently? This cannot be undone."
50
+ notice_removed: "%{filename} removed"
51
+ notice_restored: File restored.
52
+ notice_purged: File deleted permanently.
53
+ notice_already_empty: There was no file to remove.
54
+ notice_not_restorable: This file can no longer be restored.
55
+ notice_not_purgeable: This file was already restored or deleted.
56
+ missing: This file has been removed.
57
+ trash_header:
58
+ zero: Removed files
59
+ one: Removed files (1)
60
+ other: Removed files (%{count})
61
+ open_trash: Show removed files
62
+ close_trash: Hide removed files
63
+ by_on: "by %{user} on %{at}"
64
+ by_system: the system
65
+ redacted_name: "[removed file]"
66
+ state_restored: "restored by %{user} on %{at}"
67
+ state_purged: "deleted permanently by %{user} on %{at}"
68
+ state_expired: expired, will be deleted
69
+ expired_pending:
70
+ one: 1 expired file waits for the nightly clean-up.
71
+ other: "%{count} expired files wait for the nightly clean-up."
72
+ expires_in:
73
+ one: "restorable until %{date} (1 day)"
74
+ other: "restorable until %{date} (%{count} days)"
75
+ reason:
76
+ removed: removed
77
+ replaced: replaced
78
+ record_destroyed: record deleted
79
+ orphaned: orphaned bytes
80
+ uploaded: uploaded
81
+ event:
82
+ uploaded: file uploaded
83
+ removed: file removed
84
+ replaced: file replaced
85
+ record_destroyed: file trashed (record deleted)
86
+ orphaned: orphaned file trashed
87
+ restored: file restored
88
+ purged: file deleted
89
+ global:
90
+ title: Removed files
91
+ model: Model
92
+ state: State
93
+ all: all
94
+ filter: filter
95
+ record: Record
96
+ record_gone: "%{model} #%{id} (deleted)"
97
+ slot: Field
98
+ file: File
99
+ trashed: Removed
100
+ actions: Actions
101
+ purge_selected: Delete selected permanently
102
+ confirm_purge_selected: Delete the selected files permanently? This cannot be undone.
103
+ purged_count:
104
+ one: 1 file deleted permanently.
105
+ other: "%{count} files deleted permanently."
106
+ nothing_selected: No files selected.
107
+ empty: No removed files.
108
+ sweep_last: "Last clean-up: %{at} (%{count} deleted)"
109
+ sweep_never: The nightly clean-up has never run.
110
+ sweep_overdue: The nightly clean-up has not run for more than 48 hours. Check the cron job.
111
+ sweep_failed: "Last clean-up failed: %{error}"
112
+ states:
113
+ trashed: in trash
114
+ restored: restored
115
+ purged: deleted
116
+ expired: expired
34
117
  pending_migration:
35
118
  notice: "pending migration \u2014 run rails db:migrate"
36
119
  succes: success.
@@ -41,3 +124,7 @@ en:
41
124
  client:
42
125
  first_name: first name
43
126
  last_name: last name
127
+ errors:
128
+ messages:
129
+ inline_forms_file_too_large: "is too large (maximum %{count} MB)"
130
+ inline_forms_file_missing: "no file chosen"