Module: Models::Publication

Extended by:
ActiveSupport::Concern
Includes:
Memery, HybridSearchable
Included in:
Item
Defined in:
app/concerns/models/publication.rb

Overview

ActiveSupport::Concern mixin: publication.

Defined Under Namespace

Modules: ClassMethods

Constant Summary collapse

CACHE_RELEVANT_ATTRIBUTES =

Attributes whose change alters what a product's edge-cached pages render for
this publication — its visibility, link text/slug, or which products surface
it. PDF/file replacement is covered separately via #publication_pdf_changed?.

%w[is_discontinued publication_base_name name sku content_url primary_product_line_id].freeze
PUBLIC_LOCALES =

Locales the public www serves a publication under (matches the www-edge
worker + admin bar locale lists and route_translator's available_locales).

%w[en-US en-CA].freeze

Instance Attribute Summary collapse

Has many collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from HybridSearchable

rrf_ranked_relation

Instance Attribute Details

#available_in_canadaObject

memoize :available_in_usa



185
186
187
# File 'app/concerns/models/publication.rb', line 185

def available_in_canada
  available_service_locales.intersect?(%i[en-CA fr-CA])
end

#available_in_usaObject



180
181
182
# File 'app/concerns/models/publication.rb', line 180

def available_in_usa
  available_service_locales.include?(:'en-US')
end

#serve_in_localeObject

Returns the value of attribute serve_in_locale.



13
14
15
# File 'app/concerns/models/publication.rb', line 13

def serve_in_locale
  @serve_in_locale
end

Class Method Details

.ai_search_publicationsActiveRecord::Relation<Models::Publication>

A relation of Models::Publications that are ai search publications. Active Record Scope

Returns:

See Also:



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# File 'app/concerns/models/publication.rb', line 63

scope :ai_search_publications, ->(query, limit: 200, max_distance: nil) {
  # Clear any previous warning
  self.last_ai_search_warning = nil

  return none if query.blank?

  # Generate query embedding via Gemini (matches stored unified vectors).
  query_embedding = ContentEmbedding.generate_query_embedding(query)
  unless query_embedding
    self.last_ai_search_warning = 'Could not generate AI embedding for your query. Please try a different search term.'
    return none
  end

  # Format vector for SQL with explicit dimension cast
  dimensions = query_embedding.size
  vector_literal = "[#{query_embedding.join(',')}]"

  # Minimum distance per item across all of its Gemini vectors (primary + chunks).
  min_distance_sql = sanitize_sql_array([
                                          <<~SQL.squish,
                                            SELECT embeddable_id,
                                                   MIN(unified_embedding::vector(#{dimensions}) <=> ?::vector(#{dimensions})) AS min_distance
                                            FROM content_embeddings_items
                                            WHERE embeddable_type = 'Item'
                                              AND embedding_model IN (?)
                                              AND unified_embedding IS NOT NULL
                                            GROUP BY embeddable_id
                                          SQL
                                          vector_literal,
                                          ContentEmbedding::UNIFIED_MODELS
                                        ])

  # Use where(id: subquery) pattern which works better with count
  matching_ids_sql = "SELECT embeddable_id FROM (#{min_distance_sql}) AS distances"
  matching_ids_sql += sanitize_sql_array([' WHERE min_distance < ?', max_distance]) if max_distance.present?

  # Get matching item IDs and order by distance
  base_query = where("#{table_name}.id IN (#{matching_ids_sql})")
               .joins("INNER JOIN (#{min_distance_sql}) AS best_match ON best_match.embeddable_id = #{table_name}.id")
               .select("#{table_name}.*", 'best_match.min_distance AS neighbor_distance')
               .order('best_match.min_distance ASC')

  base_query.limit(limit)
}

.ai_search_warning?Boolean

Check if the last AI search had issues (call from controller after running scope)

Returns:

  • (Boolean)


54
55
56
# File 'app/concerns/models/publication.rb', line 54

def self.ai_search_warning?
  last_ai_search_warning.present?
end

.clear_ai_search_warning!Object

Clear the AI search warning



59
60
61
# File 'app/concerns/models/publication.rb', line 59

def self.clear_ai_search_warning!
  self.last_ai_search_warning = nil
end

.cover_ratio_mismatchActiveRecord::Relation<Models::Publication>

A relation of Models::Publications that are cover ratio mismatch. Active Record Scope

Returns:

See Also:



127
128
129
130
131
# File 'app/concerns/models/publication.rb', line 127

scope :cover_ratio_mismatch, ->(flag = true) {
  return all unless ActiveModel::Type::Boolean.new.cast(flag)

  where(primary_image_id: Image.not_letter_ratio.select(:id))
}

.embeddable_content_typesObject

Content types for publication embeddings



507
508
509
# File 'app/concerns/models/publication.rb', line 507

def self.embeddable_content_types
  [:primary]
end

.hybrid_search_publicationsActiveRecord::Relation<Models::Publication>

A relation of Models::Publications that are hybrid search publications. Active Record Scope

Returns:

See Also:



109
110
111
112
113
114
115
116
117
118
119
120
# File 'app/concerns/models/publication.rb', line 109

scope :hybrid_search_publications, ->(query, limit: 200) {
  return none if query.blank?

  ai_ids = ai_search_publications(query, limit: limit).ids
  keyword_ids = begin
    keywords_search(query).limit(limit).ids
  rescue PgSearch::EmptyQueryError
    []
  end

  rrf_ranked_relation(ai_ids, keyword_ids, limit: limit)
}

.publicationsActiveRecord::Relation<Models::Publication>

A relation of Models::Publications that are publications. Active Record Scope

Returns:

See Also:



21
# File 'app/concerns/models/publication.rb', line 21

scope :publications, -> { where(arel_table[:pc_path_slugs].ltree_descendant(LtreePaths::PC_PUBLICATIONS)) }

.publications_for_online_portalActiveRecord::Relation<Models::Publication>

A relation of Models::Publications that are publications for online portal. Active Record Scope

Returns:

See Also:



28
# File 'app/concerns/models/publication.rb', line 28

scope :publications_for_online_portal, -> { publications.active.where(product_category_id: ProductCategory.where.any_of({ show_in_sales_portal: true }, { show_in_support_portal: true }).select(:id)) }

.publications_for_publicActiveRecord::Relation<Models::Publication>

A relation of Models::Publications that are publications for public. Active Record Scope

Returns:

See Also:



24
# File 'app/concerns/models/publication.rb', line 24

scope :publications_for_public, -> { publications_for_public_in_store(Store::PUBLIC_STORE_IDS) }

.publications_for_public_in_storeActiveRecord::Relation<Models::Publication>

A relation of Models::Publications that are publications for public in store. Active Record Scope

Returns:

See Also:



23
# File 'app/concerns/models/publication.rb', line 23

scope :publications_for_public_in_store, ->(*store_ids) { publications.with_publication_attached.active.in_store(store_ids) }

.publications_for_sales_portalActiveRecord::Relation<Models::Publication>

A relation of Models::Publications that are publications for sales portal. Active Record Scope

Returns:

See Also:



27
# File 'app/concerns/models/publication.rb', line 27

scope :publications_for_sales_portal, -> { publications.active.where(product_category_id: ProductCategory.for_sales_portal.select(:id)) }

.publications_for_support_portalActiveRecord::Relation<Models::Publication>

A relation of Models::Publications that are publications for support portal. Active Record Scope

Returns:

See Also:



26
# File 'app/concerns/models/publication.rb', line 26

scope :publications_for_support_portal, -> { publications.active.where(product_category_id: ProductCategory.for_support_portal.select(:id)) }

.with_publication_attachedActiveRecord::Relation<Models::Publication>

A relation of Models::Publications that are with publication attached. Active Record Scope

Returns:

See Also:



22
# File 'app/concerns/models/publication.rb', line 22

scope :with_publication_attached, -> { joins(:literature).includes(:literature) }

Instance Method Details

#alias_skuObject

Store the new sku in our alias chain



354
355
356
357
358
359
360
361
# File 'app/concerns/models/publication.rb', line 354

def alias_sku
  return unless is_publication?

  self.sku_aliases ||= []
  sku_aliases.delete(sku)
  sku_aliases << sku_was if sku_changed?
  self.sku_aliases = sku_aliases.filter_map(&:presence).uniq
end

#analyze_pdf_images!(force: false) ⇒ Object

Queue vision analysis for this publication's PDF images



576
577
578
579
580
# File 'app/concerns/models/publication.rb', line 576

def analyze_pdf_images!(force: false)
  return unless is_publication?

  PublicationVisionWorker.perform_async(id, force: force)
end

#available_service_localesObject



172
173
174
# File 'app/concerns/models/publication.rb', line 172

def available_service_locales
  store_items.active.eager_load(store: :country).map { |si| si.store.locales_served }.flatten.uniq & LocaleUtility.service_locales
end

#available_to_all_locales?Boolean

Returns:

  • (Boolean)


176
177
178
# File 'app/concerns/models/publication.rb', line 176

def available_to_all_locales?
  available_service_locales.size == LocaleUtility.service_locales.size
end

#check_redirection_pathObject



273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
# File 'app/concerns/models/publication.rb', line 273

def check_redirection_path
  return if redirection_path.blank?

  # Check that our  path is a valid uri
  begin
    uri = Addressable::URI.parse(redirection_path)
    # canonicalize our url
    uri.path = uri.path.gsub(%r{^/(en|fr)-(US|CA)}, '')
    uri.host = WEB_HOSTNAME_WITHOUT_PORT
    uri.port = APP_PORT_NUMBER unless APP_PORT_NUMBER == 80
    uri.scheme = 'https'
    self.redirection_path = uri.to_s
  rescue StandardError => e
    errors.add(:redirection_path, "redirection path could not be parsed, #{e}")
  end
end

#compose_name_with_languagesObject



198
199
200
201
202
203
204
205
206
# File 'app/concerns/models/publication.rb', line 198

def compose_name_with_languages
  return if publication_base_name.blank?

  n = publication_base_name.dup
  if (fln = friendly_locale_names).present?
    n << " (#{fln.join(', ')})"
  end
  n
end

#content_for_embedding(_content_type = :primary) ⇒ Object

Generate content for semantic search embedding
Uses extracted PDF text plus metadata



513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
# File 'app/concerns/models/publication.rb', line 513

def content_for_embedding(_content_type = :primary)
  return nil unless is_publication?

  parts = []

  # Title and basic info
  parts << "Publication: #{publication_base_name}" if publication_base_name.present?
  parts << "SKU: #{sku}" if sku.present?

  # Product line context (what product is this documentation for?)
  parts << "Product Line: #{primary_product_line.lineage_expanded}" if primary_product_line.present?

  if product_lines.any?
    other_pls = product_lines.reject { |pl| pl == primary_product_line }
    parts << "Related Products: #{other_pls.map(&:name).join(', ')}" if other_pls.any?
  end

  # Category context (installation manual, datasheet, etc.)
  parts << "Type: #{product_category.name}" if product_category.present?

  # Languages
  parts << "Languages: #{friendly_locale_names.join(', ')}" if friendly_locale_names.any?

  # Curator-supplied search keywords (boost discoverability for known query terms)
  parts << "Keywords: #{search_keywords}" if search_keywords.present?

  # Claude's full-PDF analysis is the primary content source when available.
  # It covers text, diagrams, tables, and illustrations in a clean structured form,
  # making raw text extraction redundant (which tends to be garbled/concatenated).
  # Fall back to search_text only when no Claude analysis has been run yet.
  if pdf_image_descriptions.present?
    parts << "Content:\n#{pdf_image_descriptions}"
  elsif search_text.present?
    parts << "Content:\n#{search_text}"
  elsif literature.present?
    extracted = retrieve_publications_search_text
    parts << "Content:\n#{extracted}" if extracted.present?
  end

  parts.compact.join("\n\n")
end

#cover_image_url(options = {}) ⇒ Object

Pulls the primary image or generates one from the PDF if needed



374
375
376
377
378
379
380
381
382
383
# File 'app/concerns/models/publication.rb', line 374

def cover_image_url(options = {})
  img_options = options.dup.symbolize_keys
  image = primary_image
  image ||= create_primary_image_from_pdf if img_options.delete(:create_if_missing)
  return unless image

  img_options[:format] ||= 'jpeg'
  img_options[:width] ||= 1200 if img_options[:size].blank?
  image.image_url(img_options)
end

#create_primary_image_from_pdfObject

Takes the PDF and generates a cover image which will be saved to the image library



386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
# File 'app/concerns/models/publication.rb', line 386

def create_primary_image_from_pdf
  # Extract literature to file
  pdf_path = begin
    literature.attachment.path
  rescue StandardError
    nil
  end
  if pdf_path.nil? || !File.exist?(pdf_path)
    logger.error "Could not pull pdf for #{sku} to generate thumbnail"
    return
  end
  logger.info "Generating thumbnail for #{sku} from PDF path #{pdf_path} with name #{name}"
  if (image = Pdf::Utility::ImageCreator.new.process(pdf_path:, name:))
    # destroy prior image if it exists
    primary_image&.destroy
    # link new one
    update_column(:primary_image_id, image.id)
  end
  image
end

#default_publication_logisticsObject

Set sensible defaults for logistics, in case this publication has to ship



364
365
366
367
368
369
370
371
# File 'app/concerns/models/publication.rb', line 364

def default_publication_logistics
  # we use a 0.05 default for weight, assuming a 5 piece regular stock paper 2000#
  # We also sort because base_weight should always be smaller than shipping weight
  self.base_weight, self.shipping_weight = [base_weight || 0.05, shipping_weight || base_weight || 0.05].sort
  self.shipping_width ||= 9
  self.shipping_length ||= 12
  self.shipping_height ||= 0.1
end

#embeddable_localesObject

For publications in multiple languages, return all locales



598
599
600
601
602
603
# File 'app/concerns/models/publication.rb', line 598

def embeddable_locales
  return ['en'] unless is_publication?

  locales = publication_locales.presence || ['en']
  locales.map(&:to_s).uniq
end

#embedding_content_changed?Boolean

Publications with PDF changes should regenerate embeddings

Returns:

  • (Boolean)


556
557
558
559
560
561
562
563
564
565
# File 'app/concerns/models/publication.rb', line 556

def embedding_content_changed?
  return false unless is_publication?

  saved_change_to_search_text? ||
    saved_change_to_search_keywords? ||
    saved_change_to_publication_base_name? ||
    saved_change_to_primary_product_line_id? ||
    saved_change_to_pdf_image_descriptions? ||
    has_literature_changed?
end

#fast_country_discontinueObject

Shortcut method when we deal with publications



296
297
298
299
300
301
302
303
304
305
306
# File 'app/concerns/models/publication.rb', line 296

def fast_country_discontinue
  unless @available_in_usa.nil?
    discontinue_usa = is_discontinued || !@available_in_usa.to_b
    perform_country_discontinue(1, discontinue_usa)
  end

  return if @available_in_canada.nil?

  discontinue_can = is_discontinued || !@available_in_canada.to_b
  perform_country_discontinue(2, discontinue_can)
end

#file_name_for_download(file_extension = nil) ⇒ Object



208
209
210
211
212
213
214
# File 'app/concerns/models/publication.rb', line 208

def file_name_for_download(file_extension = nil)
  return unless literature

  file_extension ||= Rack::Mime::MIME_TYPES.invert[literature.mime_type]
  file_extension ||= '.pdf'
  "#{name.parameterize}-#{id}#{file_extension}"
end

#friendly_locale_namesObject



168
169
170
# File 'app/concerns/models/publication.rb', line 168

def friendly_locale_names
  (publication_locales || []).map { |l| LocaleUtility.language_name(l) }.uniq.compact
end

#generate_publication_nameObject

Embeds languages in name



217
218
219
220
221
222
# File 'app/concerns/models/publication.rb', line 217

def generate_publication_name
  self.publication_locales = ['en'] if publication_locales.blank?
  self.name_en = compose_name_with_languages
  self.name_en_us = nil
  self.name_en_ca = nil
end

#has_literature_changed?Boolean

Returns:

  • (Boolean)


407
408
409
410
411
# File 'app/concerns/models/publication.rb', line 407

def has_literature_changed?
  return false unless literature

  literature.saved_change_to_attachment_uid? || literature.attachment_uid_changed?
end

#item_embeddingsActiveRecord::Relation<ContentEmbedding::ItemEmbedding>

Association to the partitioned embeddings table for publication AI search

Returns:

See Also:



31
32
33
# File 'app/concerns/models/publication.rb', line 31

has_many :item_embeddings, -> { where(embeddable_type: 'Item') },
class_name: 'ContentEmbedding::ItemEmbedding',
foreign_key: :embeddable_id

#locale_for_embeddingObject

Locale for embedding - uses publication_locales
Publications can be in multiple languages, returns the first/primary



591
592
593
594
595
# File 'app/concerns/models/publication.rb', line 591

def locale_for_embedding
  return 'en' unless is_publication?

  publication_locales&.first.to_s.presence || 'en'
end

#needs_vision_analysis?Boolean

Check if vision analysis is needed for this publication

Returns:

  • (Boolean)


568
569
570
571
572
573
# File 'app/concerns/models/publication.rb', line 568

def needs_vision_analysis?
  return false unless is_publication?
  return false unless literature&.attachment_stored?

  pdf_images_analyzed_at.blank?
end

#perform_country_discontinue(store_id, discontinue) ⇒ Object



308
309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'app/concerns/models/publication.rb', line 308

def perform_country_discontinue(store_id, discontinue)
  si = store_items.where(store_id: store_id).first_or_initialize(unit_cogs: 0, qty_on_hand: 0, qty_committed: 0, handling_charge: 0, location: 'AVAILABLE')
  if discontinue
    unless si.new_record?
      si.catalog_items.each(&:discontinue)
      si.is_discontinued = true
      si.save
    end
  else
    si.is_discontinued = false
    si.save
    si.catalog_items.each(&:activate) # implicit unhide
  end
end

#product_category_must_be_publicationObject



156
157
158
159
160
# File 'app/concerns/models/publication.rb', line 156

def product_category_must_be_publication
  return if product_category&.is_publication?

  errors.add(:product_category_id, 'cannot be a non-publication category')
end

#publication_available_localesObject

memoize :available_in_canada



190
191
192
# File 'app/concerns/models/publication.rb', line 190

def publication_available_locales
  available_service_locales
end

#publication_base_name_clean_of_languageObject



224
225
226
227
228
229
230
231
# File 'app/concerns/models/publication.rb', line 224

def publication_base_name_clean_of_language
  words_list = publication_base_name.to_s.downcase.split(/(\w+)/).map { |l| l if l.present? && l.length > 3 }.uniq.compact
  languages = LocaleUtility.available_languages.map(&:downcase)
  matches = words_list & languages
  return if matches.blank?

  errors.add(:publication_base_name, "should not include the language: #{matches.join(', ')}, this is auto generated if you specify it in locale/language")
end

#publication_cache_relevant_change?Boolean

True when a publication change should refresh the edge cache of the products
that surface it: a revision swap flips is_discontinued (and creates a new
active record), a rename changes the PDF link slug, a replaced PDF changes the
document. Drives Events::PublicationUpdated.

Returns:

  • (Boolean)


433
434
435
436
437
438
439
# File 'app/concerns/models/publication.rb', line 433

def publication_cache_relevant_change?
  return false unless is_publication?
  return true if destroyed? || previously_new_record?
  return true if publication_pdf_changed?

  saved_changes.keys.intersect?(CACHE_RELEVANT_ATTRIBUTES)
end

#publication_edge_cache_urls(previous_product_line_id: nil) ⇒ Object

Every edge-cached www URL where this publication's PDF is surfaced, so one
purge refreshes them all after a revision/discontinue/rename/PDF replacement:

  • product PDPs that surface it — the directly-attached items plus the
    products in its primary product line (both are how
    Item::PublicationRetriever finds it) — including their lazy /section/
    document fragments, across both locales (Item#edge_cache_urls);
  • the product line landing page(s), which render documents INLINE (no lazy
    /section/ endpoint — see Www::ProductLinePresenter), across both locales;
  • the publication's own //publications/.pdf file URL.
    Used by Publication::CachePurgeHandler.

Parameters:

  • previous_product_line_id (Integer, nil) (defaults to: nil)

    when the publication was just
    reassigned to a different primary product line, the OLD line id, so pages
    it moved away from are refreshed too.



472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
# File 'app/concerns/models/publication.rb', line 472

def publication_edge_cache_urls(previous_product_line_id: nil)
  urls = []
  product_ids = specific_items.ids

  # The current line plus (on a reassignment) the previous line, including
  # their descendants — the publication surfaces on products/landing pages at
  # its line and below (see Item::PublicationRetriever).
  line_ids = []
  line_ids |= primary_product_line.self_and_descendants_ids if primary_product_line
  if previous_product_line_id.present? && (prev = ProductLine.find_by(id: previous_product_line_id))
    line_ids |= prev.self_and_descendants_ids
  end

  if line_ids.present?
    product_ids |= Item.where(primary_product_line_id: line_ids).ids
    urls += SiteMap.where(resource_type: 'ProductLine', resource_id: line_ids).map(&:url)
  end

  if product_ids.present?
    urls += Item.where(id: product_ids)
                .includes(:site_maps, :catalog_item_site_maps)
                .flat_map(&:edge_cache_urls)
  end

  urls += PUBLIC_LOCALES.map { |loc| "#{WEB_URL}/#{loc}/publications/#{sku}.pdf" } if sku.present?

  urls.uniq
end

#publication_pdf_changed?Boolean

Returns:

  • (Boolean)


413
414
415
# File 'app/concerns/models/publication.rb', line 413

def publication_pdf_changed?
  saved_change_to_literature_id? || has_literature_changed?
end

#publication_sku_checkObject

Check that our publication is formatted with the version



234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
# File 'app/concerns/models/publication.rb', line 234

def publication_sku_check
  return true unless product_category.present? && is_publication?
  return true if sku.blank? && publication_base_name.blank?

  self.sku ||= publication_base_name&.parameterize&.upcase&.gsub(/[^a-zA-Z0-9-]/, '-')&.squeeze('-')

  original_sku = sku
  sku_match = sku.match(/(.*)-([[:alpha:]])$/)
  # Upcase by default
  res = false
  if sku_match && (sku_match.length == 3)
    self.sku = "#{sku_match[1]}-#{sku_match[2]}"
    sku_match[1]
    res = true
  elsif sku.present?
    # implicit -A
    sku
    self.sku = "#{sku}-A"
    res = true
  else
    errors.add(:sku, 'for publications must be formatted with a trailing alphabetical revision indicator, such as -a, -b, etc.')
  end
  self.sku = sku.upcase.gsub(/[_ ]/, '-').squeeze('-')
  if (public_short_name == original_sku) || (public_short_name == name)
    self.public_short_name = nil # redundant
  end
  if detailed_description_html == original_sku
    self.detailed_description_html = nil # useless, we want real description
  end
  res
end

#publication_urlObject



347
348
349
350
351
# File 'app/concerns/models/publication.rb', line 347

def publication_url
  return unless is_publication?

  "https://#{WEB_HOSTNAME}/publications/#{sku}"
end

#publication_visible_to_public?Boolean

Returns:

  • (Boolean)


194
195
196
# File 'app/concerns/models/publication.rb', line 194

def publication_visible_to_public?
  literature.present? && available_service_locales.present?
end

#publish_pdf_changed_eventObject



417
418
419
420
421
422
# File 'app/concerns/models/publication.rb', line 417

def publish_pdf_changed_event
  Rails.configuration.event_store.publish(
    Events::PublicationPdfChanged.new(data: { item_id: id }),
    stream_name: "Publication-#{id}"
  )
end

#publish_publication_updated_eventObject



441
442
443
444
445
446
447
448
449
450
451
452
# File 'app/concerns/models/publication.rb', line 441

def publish_publication_updated_event
  data = { item_id: id }
  # On a product-line reassignment, carry the OLD line id so the handler also
  # purges pages where this publication NO LONGER appears (they'd otherwise
  # keep listing it until TTL). saved_change_* is available in after_commit.
  line_change = saved_change_to_primary_product_line_id
  data[:previous_primary_product_line_id] = line_change.first if line_change
  Rails.configuration.event_store.publish(
    Events::PublicationUpdated.new(data:),
    stream_name: "Publication-#{id}"
  )
end

#retrieve_publications_search_textObject



323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
# File 'app/concerns/models/publication.rb', line 323

def retrieve_publications_search_text
  return unless is_publication?
  return unless literature&.attachment
  return unless File.exist?(literature.attachment.path)
  return unless literature.attachment.format == 'pdf'

  begin
    require 'pdf/reader'
    reader = PDF::Reader.new(literature.attachment.path)
    content = reader.pages.map(&:text).join("\n")
    content = content.squish
    content = content.squeeze('_') # removes ______
    content = content.squeeze('.') # removes .......
    content = content.squeeze('-') # removes .......
    content = content.gsub(/ [_-]/, ' ')
    content = content.gsub(/[":•]/, '')
    content = content.squish
    ActionController::Base.helpers.sanitize(content) # sometimes we get crap so we have to do this, e.g. null string
  rescue StandardError => e
    logger.error "Unable to parse pdf content for publication id #{id}"
    logger.error e.inspect
  end
end

#secondary_product_category_must_not_be_publicationObject



162
163
164
165
166
# File 'app/concerns/models/publication.rb', line 162

def secondary_product_category_must_not_be_publication
  return unless secondary_product_category&.is_publication?

  errors.add(:secondary_product_category_id, 'cannot be a publication category')
end

#set_search_textObject



266
267
268
269
270
271
# File 'app/concerns/models/publication.rb', line 266

def set_search_text
  content = retrieve_publications_search_text
  return if content.blank?

  update_column(:search_text, retrieve_publications_search_text)
end

#should_queue_embedding?Boolean

Only embed active, public publications

Returns:

  • (Boolean)


583
584
585
586
587
# File 'app/concerns/models/publication.rb', line 583

def should_queue_embedding?
  return false unless is_publication?

  super && !is_discontinued? && publication_visible_to_public?
end