iron-cms 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: df1628d66898cfa63c2b67f3c56356c2a54ab28e9dda6847bd09e8d858387faf
4
- data.tar.gz: fed29be07cb73783aa5c17d985dd80601657fd82ed73924eac39a00d2eb83122
3
+ metadata.gz: 579a71fed1f35c24d51560f6055a00ab5d8072b6f2ff934e64a80363f86a6690
4
+ data.tar.gz: 3d512392984fa15623c10c13dc06500a4f839966404f12849f55ce3cf894031b
5
5
  SHA512:
6
- metadata.gz: ad72aa423676395f52e94681ea4a0d273338fdea905a82d8251ded0fe724b7b9ec36fef7188fe5cb839ac5fbcf4702101b7fe834955b4be12ac33c20a5c26de2
7
- data.tar.gz: d8690faeabdf6e02edc3756f733017e5a8eff33974202e9fc87ffc604a75801681ba8b3e30c6376a0e8ba40099cca81bc6917cc1dd3e9d35dad2c69dc3b55a10
6
+ metadata.gz: 3d39a0aeeaaa0a9b5e07f6fa60b4e18b078d4cdae252565a984ad54980ffc1e08ec8d2abcf9dcf7f168999fa9f68f3c7cb0b272b2dcc96f2439f90e61429724e
7
+ data.tar.gz: fc378916749f9802f668e61b303d86d563b2e910269cddc3b053770dca41ec1d2d5b71dbbd551756f76c90817a801bddf809e3c768ea62d60d9b9bbb716a0111
@@ -42,7 +42,7 @@ module Iron
42
42
  end
43
43
 
44
44
  def field_definition_params
45
- params.expect(field_definition: [ :type, :name, :handle, :rank, :allowed_values_text, :required, :file_scope, selected_presets: [], supported_block_definition_ids: [], supported_content_type_ids: [] ])
45
+ params.expect(field_definition: [ :type, :name, :handle, :rank, :allowed_values_text, :required, :searchable, :file_scope, selected_presets: [], supported_block_definition_ids: [], supported_content_type_ids: [] ])
46
46
  end
47
47
 
48
48
  def selected_field_definition_type
@@ -0,0 +1,7 @@
1
+ module Iron
2
+ class ReindexSearchJob < ApplicationJob
3
+ def perform(field_definition)
4
+ field_definition.reindex_entries
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,85 @@
1
+ module Iron
2
+ module Entry::Searchable
3
+ extend ActiveSupport::Concern
4
+
5
+ SEARCH_CONTENT_LIMIT = 32.kilobytes
6
+
7
+ included do
8
+ has_many :search_records, dependent: :destroy
9
+
10
+ after_create_commit :reindex
11
+ after_update_commit :reindex
12
+
13
+ scope :search, ->(query, locale: nil) {
14
+ parsed = Search::Query.new(query)
15
+ return none if parsed.blank?
16
+
17
+ joins(:search_records)
18
+ .merge(SearchRecord.matching(parsed.to_s))
19
+ .merge(SearchRecord.for_locale(locale))
20
+ .distinct
21
+ }
22
+ end
23
+
24
+ def reindex
25
+ searchable_definitions = collect_searchable_definitions
26
+
27
+ if searchable_definitions.empty?
28
+ search_records.destroy_all
29
+ return
30
+ end
31
+
32
+ active_locale_ids = fields.map(&:locale_id).uniq
33
+ Locale.where(id: active_locale_ids).find_each do |locale|
34
+ title_text = title_field_for(locale)&.value
35
+ content_text = assemble_search_content(searchable_definitions, locale)
36
+
37
+ if title_text.present? || content_text.present?
38
+ SearchRecord.upsert!(
39
+ entry_id: id,
40
+ locale_id: locale.id,
41
+ title: title_text,
42
+ content: content_text,
43
+ created_at: created_at || Time.current
44
+ )
45
+ else
46
+ search_records.where(locale:).destroy_all
47
+ end
48
+ end
49
+
50
+ search_records.where.not(locale_id: active_locale_ids).destroy_all
51
+ end
52
+
53
+ private
54
+ def collect_searchable_definitions(field_definitions = content_type.field_definitions.reload, visited = Set.new)
55
+ field_definitions.flat_map { |fd|
56
+ nested = case fd
57
+ when FieldDefinitions::Block
58
+ block_def = fd.supported_block_definition
59
+ block_def && visited.add?(block_def.id) ? collect_searchable_definitions(block_def.field_definitions, visited) : []
60
+ when FieldDefinitions::BlockList
61
+ fd.supported_block_definitions.flat_map { |bd|
62
+ visited.add?(bd.id) ? collect_searchable_definitions(bd.field_definitions, visited) : []
63
+ }
64
+ else
65
+ []
66
+ end
67
+
68
+ fd.searchable? ? [ fd, *nested ] : nested
69
+ }
70
+ end
71
+
72
+ def assemble_search_content(definitions, locale)
73
+ definition_ids = definitions.map(&:id).to_set
74
+
75
+ matching_fields = fields.select { |f|
76
+ f.locale_id == locale.id && definition_ids.include?(f.definition_id)
77
+ }
78
+
79
+ matching_fields
80
+ .filter_map(&:searchable_text)
81
+ .join(" ")
82
+ .truncate_bytes(SEARCH_CONTENT_LIMIT, omission: "")
83
+ end
84
+ end
85
+ end
@@ -15,17 +15,20 @@ module Iron
15
15
  end
16
16
 
17
17
  def title
18
- return default_title if title_field.blank?
18
+ field = title_field_for(Current.locale || Locale.default)
19
+ return default_title if field.blank?
19
20
 
20
- title_field.value.presence || default_title
21
+ field.value.presence || default_title
21
22
  end
22
23
 
23
24
  private
24
25
 
25
- def title_field
26
+ def title_field_for(locale)
26
27
  return nil unless content_type.title_field_definition.present?
27
28
 
28
- @title_field ||= fields.find_by(definition: content_type.title_field_definition)
29
+ fields.find { |f|
30
+ f.definition_id == content_type.title_field_definition_id && f.locale_id == locale.id
31
+ }
29
32
  end
30
33
 
31
34
  def default_title
@@ -1,6 +1,6 @@
1
1
  module Iron
2
2
  class Entry < ApplicationRecord
3
- include Titlable, Schemable, WebPublishable, Presentable, DeepValidation, InstanceScoped, Exportable, Importable
3
+ include Titlable, Schemable, Searchable, WebPublishable, Presentable, DeepValidation, InstanceScoped, Exportable, Importable
4
4
 
5
5
  belongs_to :creator, class_name: "Iron::User", default: -> { Current.user }
6
6
  has_many :fields, inverse_of: :entry, dependent: :destroy
@@ -13,6 +13,10 @@ module Iron
13
13
  type.demodulize.underscore
14
14
  end
15
15
 
16
+ def searchable_text
17
+ nil
18
+ end
19
+
16
20
  def value
17
21
  raise "Field type '#{type}' value method not supported"
18
22
  end
@@ -0,0 +1,10 @@
1
+ module Iron
2
+ module FieldDefinition::Ranked
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ has_rank scoped_by: :schemable
7
+ before_create :move_to_bottom
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,54 @@
1
+ module Iron
2
+ module FieldDefinition::Searchable
3
+ extend ActiveSupport::Concern
4
+
5
+ SEARCHABLE_TYPES = %w[Iron::FieldDefinitions::TextField Iron::FieldDefinitions::TextArea Iron::FieldDefinitions::RichTextArea].freeze
6
+
7
+ included do
8
+ scope :searchable, -> { where(type: SEARCHABLE_TYPES).where("json_extract(metadata, '$.searchable') = 1") }
9
+
10
+ after_update_commit :reindex_entries_later, if: :searchable_previously_changed?
11
+ end
12
+
13
+ def searchable
14
+ ActiveModel::Type::Boolean.new.cast(metadata&.dig("searchable"))
15
+ end
16
+
17
+ def searchable?
18
+ !!searchable
19
+ end
20
+
21
+ def searchable=(value)
22
+ self.metadata = (metadata || {}).merge("searchable" => ActiveModel::Type::Boolean.new.cast(value))
23
+ end
24
+
25
+ def reindex_entries
26
+ entries_for_reindex.find_each(&:reindex)
27
+ end
28
+
29
+ private
30
+ def reindex_entries_later
31
+ ReindexSearchJob.perform_later(self)
32
+ end
33
+
34
+ def searchable_previously_changed?
35
+ return false unless saved_change_to_metadata?
36
+
37
+ old_metadata = saved_changes["metadata"]&.first || {}
38
+ new_metadata = saved_changes["metadata"]&.last || {}
39
+
40
+ old_metadata["searchable"] != new_metadata["searchable"]
41
+ end
42
+
43
+ def entries_for_reindex
44
+ case schemable
45
+ when ContentType
46
+ schemable.entries
47
+ when BlockDefinition
48
+ Entry.joins(:fields).where(iron_fields: { definition_id: schemable.referencing_field_definition_ids }).distinct
49
+ else
50
+ Entry.none
51
+ end
52
+ end
53
+ end
54
+ end
@@ -1,14 +1,11 @@
1
1
  module Iron
2
2
  class FieldDefinition < ApplicationRecord
3
- include Exportable, Importable
3
+ include Ranked, Searchable, Exportable, Importable
4
4
 
5
5
  TYPES = %w[text_field text_area rich_text_area number file boolean date block block_list reference_list reference].freeze
6
6
 
7
7
  belongs_to :schemable, polymorphic: true
8
8
 
9
- has_rank scoped_by: :schemable
10
- before_create :move_to_bottom
11
-
12
9
  has_many :fields, as: :definition, inverse_of: :definition, dependent: :destroy
13
10
 
14
11
  has_one :titlable_content_type, class_name: "Iron::ContentType", inverse_of: :title_field_definition, dependent: :nullify
@@ -70,19 +70,7 @@ module Iron
70
70
  end
71
71
 
72
72
  def title
73
- text_types = %w[Iron::Fields::TextField Iron::Fields::TextArea Iron::Fields::RichTextArea]
74
-
75
- text_field = fields.find { |f| text_types.include?(f.type) }
76
- return nil unless text_field
77
-
78
- content = case text_field
79
- when Fields::RichTextArea
80
- text_field.rich_text&.to_plain_text
81
- else
82
- text_field.value
83
- end
84
-
85
- content&.truncate(300)
73
+ fields.filter_map(&:searchable_text).first&.truncate(300)
86
74
  end
87
75
 
88
76
  def thumbnail
@@ -4,6 +4,10 @@ module Iron
4
4
 
5
5
  has_rich_text :rich_text
6
6
 
7
+ def searchable_text
8
+ rich_text&.to_plain_text
9
+ end
10
+
7
11
  def value
8
12
  rich_text&.to_s
9
13
  end
@@ -1,5 +1,9 @@
1
1
  module Iron
2
2
  class Fields::TextArea < Field
3
+ def searchable_text
4
+ value
5
+ end
6
+
3
7
  def value
4
8
  value_text
5
9
  end
@@ -2,6 +2,10 @@ module Iron
2
2
  class Fields::TextField < Field
3
3
  validate :validate_required_field
4
4
 
5
+ def searchable_text
6
+ value
7
+ end
8
+
5
9
  def value
6
10
  value_string
7
11
  end
@@ -0,0 +1,33 @@
1
+ module Iron
2
+ class Search::Query
3
+ attr_reader :terms
4
+
5
+ def initialize(raw)
6
+ @terms = sanitize(raw)
7
+ end
8
+
9
+ def blank? = terms.blank?
10
+ def to_s = terms.to_s
11
+
12
+ private
13
+ def sanitize(raw)
14
+ return nil if raw.blank?
15
+ result = strip_special_characters(raw)
16
+ return nil if result.blank?
17
+ result = remove_unbalanced_quotes(result)
18
+ neutralize_operators(result)
19
+ end
20
+
21
+ def strip_special_characters(text)
22
+ text.gsub(/[^\w\s"]/, "").squish
23
+ end
24
+
25
+ def remove_unbalanced_quotes(text)
26
+ text.count('"').even? ? text : text.delete('"')
27
+ end
28
+
29
+ def neutralize_operators(text)
30
+ text.downcase
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,20 @@
1
+ module Iron
2
+ class SearchRecord::Fts < ApplicationRecord
3
+ self.table_name = "iron_search_records_fts"
4
+ self.primary_key = "rowid"
5
+
6
+ attribute :rowid, :integer
7
+ attribute :title, :string
8
+ attribute :content, :string
9
+
10
+ scope :with_rowid, -> { select(:rowid, :title, :content) }
11
+
12
+ def self.upsert(rowid, title, content)
13
+ connection.exec_query(
14
+ "INSERT OR REPLACE INTO iron_search_records_fts(rowid, title, content) VALUES (?, ?, ?)",
15
+ "Iron::SearchRecord::Fts Upsert",
16
+ [ rowid, title, content ]
17
+ )
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,31 @@
1
+ module Iron
2
+ class SearchRecord < ApplicationRecord
3
+ belongs_to :entry
4
+ belongs_to :locale
5
+
6
+ has_one :search_records_fts, -> { with_rowid },
7
+ class_name: "Iron::SearchRecord::Fts", foreign_key: :rowid, primary_key: :id, dependent: :destroy
8
+
9
+ after_save :upsert_to_fts5_table
10
+
11
+ scope :matching, ->(query) { joins(:search_records_fts).where("iron_search_records_fts MATCH ?", query) }
12
+ scope :for_locale, ->(locale) { locale ? where(locale:) : all }
13
+
14
+ class << self
15
+ def upsert!(attributes)
16
+ record = find_by(entry_id: attributes[:entry_id], locale_id: attributes[:locale_id])
17
+ if record
18
+ record.update!(attributes)
19
+ record
20
+ else
21
+ create!(attributes)
22
+ end
23
+ end
24
+ end
25
+
26
+ private
27
+ def upsert_to_fts5_table
28
+ Fts.upsert(id, title, content)
29
+ end
30
+ end
31
+ end
@@ -1,2 +1,6 @@
1
- <%= render layout: "iron/field_definitions/layouts/form", locals: { field_definition: field_definition } do %>
1
+ <%= render layout: "iron/field_definitions/layouts/form", locals: { field_definition: field_definition } do |form| %>
2
+ <div class="field flex items-center justify-between">
3
+ <%= form.label :searchable, t("iron.field_definitions.rich_text_area.searchable") %>
4
+ <%= form.toggle :searchable %>
5
+ </div>
2
6
  <% end %>
@@ -1,2 +1,6 @@
1
- <%= render layout: "iron/field_definitions/layouts/form", locals: { field_definition: field_definition } do %>
1
+ <%= render layout: "iron/field_definitions/layouts/form", locals: { field_definition: field_definition } do |form| %>
2
+ <div class="field flex items-center justify-between">
3
+ <%= form.label :searchable, t("iron.field_definitions.text_area.searchable") %>
4
+ <%= form.toggle :searchable %>
5
+ </div>
2
6
  <% end %>
@@ -4,6 +4,11 @@
4
4
  <%= form.toggle :required %>
5
5
  </div>
6
6
 
7
+ <div class="field flex items-center justify-between">
8
+ <%= form.label :searchable, t("iron.field_definitions.text_field.searchable") %>
9
+ <%= form.toggle :searchable %>
10
+ </div>
11
+
7
12
  <div class="field">
8
13
  <%= form.label :allowed_values_text, t("iron.field_definitions.text_field.allowed_values") %>
9
14
  <div>
@@ -207,10 +207,15 @@ en:
207
207
  title: "New Field"
208
208
  reference:
209
209
  supported_content_types: "Supported content types"
210
+ rich_text_area:
211
+ searchable: "Searchable"
212
+ text_area:
213
+ searchable: "Searchable"
210
214
  text_field:
211
215
  allowed_values: "Allowed values"
212
216
  allowed_values_help: "One value per line, leave blank to allow any value"
213
217
  required: "Required"
218
+ searchable: "Searchable"
214
219
  first_runs:
215
220
  show:
216
221
  description: "Start managing your content with Iron CMS"
@@ -0,0 +1,31 @@
1
+ class CreateIronSearchRecords < ActiveRecord::Migration[8.1]
2
+ def up
3
+ create_table :iron_search_records do |t|
4
+ t.references :entry, null: false, foreign_key: { to_table: :iron_entries }
5
+ t.references :locale, null: false, foreign_key: { to_table: :iron_locales }
6
+ t.string :title
7
+ t.text :content
8
+ t.datetime :created_at, null: false
9
+
10
+ t.index [ :entry_id, :locale_id ], unique: true
11
+ end
12
+
13
+ return unless connection.adapter_name == "SQLite"
14
+
15
+ execute <<-SQL
16
+ CREATE VIRTUAL TABLE iron_search_records_fts USING fts5(
17
+ title,
18
+ content,
19
+ tokenize='porter'
20
+ )
21
+ SQL
22
+ end
23
+
24
+ def down
25
+ if connection.adapter_name == "SQLite"
26
+ execute "DROP TABLE IF EXISTS iron_search_records_fts"
27
+ end
28
+
29
+ drop_table :iron_search_records, if_exists: true
30
+ end
31
+ end
data/lib/iron/engine.rb CHANGED
@@ -12,6 +12,7 @@ require "iron/sdk"
12
12
  require "pagy"
13
13
  require "iron/routing"
14
14
  require "iron/image_analyzer"
15
+ require "iron/fts5"
15
16
 
16
17
  module Iron
17
18
  class Engine < ::Rails::Engine
@@ -58,6 +59,12 @@ module Iron
58
59
  end
59
60
  end
60
61
 
62
+ initializer "iron.fts5" do |app|
63
+ app.config.after_initialize do
64
+ ActiveSupport.on_load(:active_record) { Fts5.ensure_virtual_table }
65
+ end
66
+ end
67
+
61
68
  config.to_prepare do
62
69
  ActionView::Base.include(Module.new do
63
70
  def attachment_url(attachment, **options)
data/lib/iron/fts5.rb ADDED
@@ -0,0 +1,23 @@
1
+ # SQLite FTS5 virtual tables are not captured by Rails' schema.rb dumper.
2
+ # When a database is created via db:schema:load (instead of running migrations),
3
+ # the FTS5 table will be missing. This ensures it exists on boot.
4
+
5
+ module Iron
6
+ module Fts5
7
+ def self.ensure_virtual_table
8
+ ActiveRecord::Base.connection_pool.with_connection do |conn|
9
+ return unless conn.adapter_name == "SQLite"
10
+ return unless conn.table_exists?(:iron_search_records)
11
+ return if conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='iron_search_records_fts'").any?
12
+
13
+ conn.execute <<~SQL
14
+ CREATE VIRTUAL TABLE iron_search_records_fts USING fts5(
15
+ title,
16
+ content,
17
+ tokenize='porter'
18
+ )
19
+ SQL
20
+ end
21
+ end
22
+ end
23
+ end
data/lib/iron/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Iron
2
- VERSION = "0.13.1"
2
+ VERSION = "0.14.0"
3
3
  end
@@ -1,4 +1,18 @@
1
1
  namespace :iron do
2
+ namespace :search do
3
+ desc "Rebuild the full-text search index for all entries"
4
+ task reindex: :environment do
5
+ puts "Clearing search records..."
6
+ ActiveRecord::Base.connection.execute("DELETE FROM iron_search_records")
7
+ ActiveRecord::Base.connection.execute("DELETE FROM iron_search_records_fts")
8
+
9
+ puts "Reindexing entries..."
10
+ Iron::Entry.find_each(&:reindex)
11
+
12
+ puts "Done! Processed #{Iron::Entry.count} entries."
13
+ end
14
+ end
15
+
2
16
  namespace :seed do
3
17
  desc "Dump CMS schema and content to db/seeds/iron.zip"
4
18
  task dump: :environment do
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: iron-cms
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.1
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Massimo De Marchi
@@ -321,6 +321,7 @@ files:
321
321
  - app/jobs/iron/export_job.rb
322
322
  - app/jobs/iron/generate_entry_routes_job.rb
323
323
  - app/jobs/iron/import_job.rb
324
+ - app/jobs/iron/reindex_search_job.rb
324
325
  - app/mailers/iron/application_mailer.rb
325
326
  - app/mailers/iron/passwords_mailer.rb
326
327
  - app/models/concerns/iron/broadcastable.rb
@@ -350,6 +351,7 @@ files:
350
351
  - app/models/iron/entry/importable.rb
351
352
  - app/models/iron/entry/presentable.rb
352
353
  - app/models/iron/entry/schemable.rb
354
+ - app/models/iron/entry/searchable.rb
353
355
  - app/models/iron/entry/titlable.rb
354
356
  - app/models/iron/entry/web_publishable.rb
355
357
  - app/models/iron/exporter.rb
@@ -358,6 +360,8 @@ files:
358
360
  - app/models/iron/field_definition.rb
359
361
  - app/models/iron/field_definition/exportable.rb
360
362
  - app/models/iron/field_definition/importable.rb
363
+ - app/models/iron/field_definition/ranked.rb
364
+ - app/models/iron/field_definition/searchable.rb
361
365
  - app/models/iron/field_definitions/block.rb
362
366
  - app/models/iron/field_definitions/block_list.rb
363
367
  - app/models/iron/field_definitions/boolean.rb
@@ -386,6 +390,9 @@ files:
386
390
  - app/models/iron/locale.rb
387
391
  - app/models/iron/qr_code_link.rb
388
392
  - app/models/iron/reference.rb
393
+ - app/models/iron/search/query.rb
394
+ - app/models/iron/search_record.rb
395
+ - app/models/iron/search_record/fts.rb
389
396
  - app/models/iron/seed.rb
390
397
  - app/models/iron/session.rb
391
398
  - app/models/iron/user.rb
@@ -523,6 +530,7 @@ files:
523
530
  - db/migrate/20251209103110_create_iron_account_imports.rb
524
531
  - db/migrate/20260207103057_add_language_to_iron_users.rb
525
532
  - db/migrate/20260209215027_add_active_to_iron_users.rb
533
+ - db/migrate/20260210220330_create_iron_search_records.rb
526
534
  - lib/generators/iron/pages/pages_generator.rb
527
535
  - lib/generators/iron/pages/templates/pages_controller.rb
528
536
  - lib/generators/iron/pages/templates/show.html.erb
@@ -531,6 +539,7 @@ files:
531
539
  - lib/iron-cms.rb
532
540
  - lib/iron.rb
533
541
  - lib/iron/engine.rb
542
+ - lib/iron/fts5.rb
534
543
  - lib/iron/global_id/instance_scoped_locator.rb
535
544
  - lib/iron/image_analyzer.rb
536
545
  - lib/iron/lexorank.rb