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

REVISION_CHAIN_LIMIT =

Guard against pathological successor loops.

50
MARKETPLACE_DOWNLOAD_TOKEN_LIFETIME =

How long a marketplace-facing download token lives. Not "forever" — nothing
here is — but far enough out that it outlives the listing rather than the
other way round.

50.years
MARKETPLACE_DOWNLOAD_TOKEN_FLOOR =

A token minted for a marketplace has to be distinguishable from an ordinary
30-day one so we reuse the right kind. Anything expiring beyond this is ours.

5.years
CACHE_RELEVANT_ATTRIBUTES =

Attributes whose change alters what an edge-cached publication card renders:
its visibility, link text/slug, cover, or which products surface it.
PDF/file replacement is covered separately via #publication_pdf_changed?.
Join-table page tags are tracked by Publication::Saver through
#publication_previous_page_paths because they never appear in saved_changes.

%w[
  is_discontinued
  publication_base_name
  name
  sku
  content_url
  primary_image_id
  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

ranked_ids, rrf_ranked_relation

Instance Attribute Details

#available_in_canadaBoolean

Whether the publication is available in Canadian locales

Returns:

  • (Boolean)


304
305
306
# File 'app/concerns/models/publication.rb', line 304

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

#available_in_usaBoolean

Whether the publication is available in the US locale.

Returns:

  • (Boolean)


297
298
299
# File 'app/concerns/models/publication.rb', line 297

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

#publication_previous_page_pathsObject

Returns the value of attribute publication_previous_page_paths.



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

def publication_previous_page_paths
  @publication_previous_page_paths
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:



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'app/concerns/models/publication.rb', line 93

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)


76
77
78
# File 'app/concerns/models/publication.rb', line 76

def self.ai_search_warning?
  last_ai_search_warning.present?
end

.clear_ai_search_warning!void

This method returns an undefined value.

Clear the AI search warning



82
83
84
# File 'app/concerns/models/publication.rb', line 82

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:



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

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_typesArray<Symbol>

Content types for publication embeddings

Returns:

  • (Array<Symbol>)

    always [:primary]



808
809
810
# File 'app/concerns/models/publication.rb', line 808

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:



139
140
141
142
143
144
145
146
147
148
149
150
# File 'app/concerns/models/publication.rb', line 139

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

  ai_ids = ranked_ids(ai_search_publications(query, limit: limit))
  keyword_ids = begin
    ranked_ids(keywords_search(query).limit(limit))
  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_skuvoid

This method returns an undefined value.

Store the new sku in our alias chain



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

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) ⇒ void

This method returns an undefined value.

Queue vision analysis for this publication's PDF images

Parameters:

  • force (Boolean) (defaults to: false)

    re-analyze even when pdf_images_analyzed_at is set



895
896
897
898
899
# File 'app/concerns/models/publication.rb', line 895

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

  PublicationVisionWorker.perform_async(id, force: force)
end

#available_service_localesArray<Symbol>

Service locales this publication is actually reachable in: the intersection of
the locales served by its active stores with the platform's service locales.

Returns:

  • (Array<Symbol>)

    e.g. [:'en-US', :'en-CA']



285
286
287
# File 'app/concerns/models/publication.rb', line 285

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

Whether the publication is available in every service locale.

Returns:

  • (Boolean)


291
292
293
# File 'app/concerns/models/publication.rb', line 291

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

#check_redirection_pathvoid

This method returns an undefined value.

Validation/normalization: parses redirection_path, strips locale prefixes
from its path, and re-canonicalizes it against the production host. Adds an
error when the path can't be parsed.



407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
# File 'app/concerns/models/publication.rb', line 407

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_languagesString?

Display name with the publication's languages appended, e.g.
"TempZone Cable Manual (English, French)". Nil when no base name is set.

Returns:

  • (String, nil)


325
326
327
328
329
330
331
332
333
# File 'app/concerns/models/publication.rb', line 325

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) ⇒ String?

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

Parameters:

  • _content_type (Symbol) (defaults to: :primary)

    embedding content type (always :primary for
    publications — see embeddable_content_types)

Returns:

  • (String, nil)

    the text to embed, or nil when not a publication



817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
# File 'app/concerns/models/publication.rb', line 817

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?

  # Two COMPLEMENTARY content layers — merged, not either/or:
  #
  #   • Document text (docling markdown, cached in +search_text+ by
  #     {UploadMarkdownExtractionWorker}): verbatim body copy, model numbers,
  #     and spec TABLES with their structure intact — the exact strings people
  #     search by. This is what the old PDF::Reader path mangled and what the
  #     vision prompt deliberately skips ("skip pages that contain only text").
  #   • Visual content (Claude's full-PDF vision analysis): diagrams, wiring,
  #     photos, and layout that docling reduces to `<!-- image -->` placeholders.
  #
  # Embedding both (chunked — see {embeddable_chunked?}) makes a publication
  # retrievable by its exact specs AND by what its diagrams depict; either
  # source alone loses one of those. A live docling extraction is the last
  # resort only when the async cache hasn't populated yet.
  document_text = search_text.presence || (literature.present? ? retrieve_publications_search_text : nil)
  parts << "Document text:\n#{document_text}" if document_text.present?
  parts << "Visual content:\n#{pdf_image_descriptions}" if pdf_image_descriptions.present?

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

#cover_image_url(options = {}) ⇒ String?

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

Parameters:

  • options (Hash) (defaults to: {})

    image url options forwarded to Image#image_url

Options Hash (options):

  • create_if_missing (Boolean)

    generate the cover image from the PDF
    when no primary image exists

  • format (String)

    image format (default: 'jpeg')

  • width (Integer)

    image width (defaults to 1200 when no :size given)

  • size (String)

    requested dimensions, e.g. '150x150'

Returns:

  • (String, nil)

    url of the cover image, or nil when none is available



585
586
587
588
589
590
591
592
593
594
# File 'app/concerns/models/publication.rb', line 585

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_pdf(publish_update: true) ⇒ Image?

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

Parameters:

  • publish_update (Boolean) (defaults to: true)

    publish Events::PublicationUpdated after the
    cover swap (false when the caller batch-publishes itself)

Returns:

  • (Image, nil)

    the generated cover image, or nil on failure



601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
# File 'app/concerns/models/publication.rb', line 601

def create_primary_image_from_pdf(publish_update: true)
  # 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.call(pdf_path:, name:))
    # destroy prior image if it exists
    primary_image&.destroy
    # link new one
    update_column(:primary_image_id, image.id)
    if publish_update
      publish_event(
        Events::PublicationUpdated,
        data: { item_id: id },
        stream_name: "Publication-#{id}"
      )
    end
  end
  image
end

#default_publication_logisticsvoid

This method returns an undefined value.

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



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

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

#durable_download_tokenDownloadToken

The long-lived token behind #publication_download_url, reused when one
already exists.

Locked on the upload because the fast path is check-then-create and there is
no unique constraint on download_tokens.upload_id to catch a duplicate —
the unique index covers token, which is generated per row and so never
collides. Two concurrent listing pushes for the same publication would
otherwise each mint one. Re-reads inside the lock so the loser of the race
returns the winner's token rather than adding its own.

Returns:



537
538
539
540
541
542
543
544
545
# File 'app/concerns/models/publication.rb', line 537

def durable_download_token
  existing = find_durable_download_token
  return existing if existing

  literature.with_lock do
    find_durable_download_token ||
      literature.download_tokens.create!(expires_at: MARKETPLACE_DOWNLOAD_TOKEN_LIFETIME.from_now)
  end
end

#embeddable_chunked?Boolean

Publications embed via the chunked path: merged document text + vision
descriptions routinely exceed the ~8k-token embedding window, so a single
truncated vector would drop the tail of long installation manuals. See
Embeddable#embeddable_chunked?.

Returns:

  • (Boolean)


868
869
870
# File 'app/concerns/models/publication.rb', line 868

def embeddable_chunked?
  is_publication?
end

#embeddable_localesArray<String>

For publications in multiple languages, return all locales

Returns:

  • (Array<String>)

    ISO language codes, defaulting to ['en']



919
920
921
922
923
924
# File 'app/concerns/models/publication.rb', line 919

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)


873
874
875
876
877
878
879
880
881
882
# File 'app/concerns/models/publication.rb', line 873

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_discontinuevoid

This method returns an undefined value.

Shortcut method when we deal with publications



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

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) ⇒ String?

Download file name for the attached literature, derived from the item name
and id. Nil when the publication has no attached file.

Parameters:

  • file_extension (String, nil) (defaults to: nil)

    override extension (defaults to the
    literature's MIME type, falling back to '.pdf')

Returns:

  • (String, nil)

    e.g. "tempzone-cable-manual-123.pdf"



340
341
342
343
344
345
346
# File 'app/concerns/models/publication.rb', line 340

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

#find_durable_download_tokenDownloadToken?

Returns the longest-lived marketplace token, if any.

Returns:

  • (DownloadToken, nil)

    the longest-lived marketplace token, if any



548
549
550
551
552
# File 'app/concerns/models/publication.rb', line 548

def find_durable_download_token
  literature.download_tokens
            .where(DownloadToken[:expires_at].gt(MARKETPLACE_DOWNLOAD_TOKEN_FLOOR.from_now))
            .order(expires_at: :desc).first
end

#friendly_locale_namesArray<String>

Human-readable language names for this publication's locales, deduped.

Returns:

  • (Array<String>)

    e.g. ['English', 'French']



208
209
210
# File 'app/concerns/models/publication.rb', line 208

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

#generate_publication_namevoid

This method returns an undefined value.

Embeds languages in name



350
351
352
353
354
355
# File 'app/concerns/models/publication.rb', line 350

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

Whether the attached literature file itself changed in this save.

Returns:

  • (Boolean)


631
632
633
634
635
# File 'app/concerns/models/publication.rb', line 631

def has_literature_changed?
  return false unless literature

  literature.saved_change_to_attachment_uid? || literature.attachment_uid_changed?
end

Links are written bidirectionally (RelatedPublication.create_bidirectional),
so the reverse direction needs its own dependent: :destroy — otherwise the
row pointing AT this publication survives its destroy and violates the FK.
translated_publication_links deliberately has NO dependent: it subsets
related_publication_links, which already owns the cleanup.

Returns:

See Also:



51
52
53
54
# File 'app/concerns/models/publication.rb', line 51

has_many :incoming_related_publication_links, class_name: 'RelatedPublication',
foreign_key: :related_publication_id,
dependent: :destroy,
inverse_of: :related_publication

#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

#latest_revisionItem

The newest revision in this publication's chain (may be self).

Returns:



247
248
249
# File 'app/concerns/models/publication.rb', line 247

def latest_revision
  revision_chain.last
end

#legacy_sku_revision_chainArray<Item>

Legacy revision listing by canonical-SKU pattern — kept ONLY as the
fallback for rows the successor backfill hasn't linked yet. Uses the
strict Item#revisions matcher (canonical + single-letter suffix; locale
variants and longer prefix-sharing SKUs excluded).

Returns:

  • (Array<Item>)

    chain including self, oldest first



278
279
280
# File 'app/concerns/models/publication.rb', line 278

def legacy_sku_revision_chain
  revisions.to_a.reverse.presence || [self]
end

#linked_revision_chainArray<Item>

Walk predecessor (superceded_items) to the chain head, then successor_item
to the tail. Returns just [self] when no pointers exist. The two walks use
SEPARATE cycle guards — a single shared seen would poison the forward
walk with the very nodes the backward walk just traversed.

Returns:

  • (Array<Item>)

    chain including self, oldest first



256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
# File 'app/concerns/models/publication.rb', line 256

def linked_revision_chain
  head = self
  back_seen = { id => true }
  while (prev = head.superceded_items.first) && !back_seen[prev.id] && back_seen.size < REVISION_CHAIN_LIMIT
    back_seen[prev.id] = true
    head = prev
  end

  chain = [head]
  fwd_seen = { head.id => true }
  while (nxt = chain.last.successor_item) && !fwd_seen[nxt.id] && fwd_seen.size < REVISION_CHAIN_LIMIT
    fwd_seen[nxt.id] = true
    chain << nxt
  end
  chain
end

#locale_for_embeddingString

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

Returns:

  • (String)

    ISO language code, defaulting to 'en'



911
912
913
914
915
# File 'app/concerns/models/publication.rb', line 911

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)


885
886
887
888
889
890
# File 'app/concerns/models/publication.rb', line 885

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) ⇒ void

This method returns an undefined value.

Discontinue (or re-activate) this publication's StoreItem in a single store,
cascading the state change to its catalog items.

Parameters:

  • store_id (Integer)

    the store to update (1 = USA, 2 = Canada)

  • discontinue (Boolean)

    true to discontinue, false to re-activate



448
449
450
451
452
453
454
455
456
457
458
459
460
461
# File 'app/concerns/models/publication.rb', line 448

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_publicationvoid

This method returns an undefined value.

Validation: the primary product category must itself be a publication category.



191
192
193
194
195
# File 'app/concerns/models/publication.rb', line 191

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_localesArray<Symbol>

Returns the locales in which the publication is available

Returns:



311
312
313
# File 'app/concerns/models/publication.rb', line 311

def publication_available_locales
  available_service_locales
end

#publication_base_name_clean_of_languagevoid

This method returns an undefined value.

Validation: the base name must not embed a language word — languages are
auto-appended from publication_locales (see #compose_name_with_languages).



360
361
362
363
364
365
366
367
# File 'app/concerns/models/publication.rb', line 360

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 cover selection changes
the card image, or a replaced PDF changes the document. Drives
Events::PublicationUpdated.

Returns:

  • (Boolean)


674
675
676
677
678
679
680
681
# File 'app/concerns/models/publication.rb', line 674

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

  saved_changes.keys.intersect?(CACHE_RELEVANT_ATTRIBUTES)
end

#publication_download_urlString?

A publicly reachable, direct-download URL for this publication's file,
served from the api subdomain.

#publication_url cannot be used for this: Www::PublicationsController
404s any publication not published to the USA or Canada store, which is
every EUROPE manual. The token is the whole authorization here, so the api
route is neither locale- nor store-gated.

?download=true sets Content-Disposition: attachment. Amazon's
compliance_media requires a direct download and leaves the attribute
suppressed for a link that merely opens the PDF in a browser.

The token is reused across calls — a listing feed rebuilds this attribute
once per locale per push, and minting a row each time would grow
download_tokens without bound.

Returns:

  • (String, nil)

    nil when this isn't a publication or has no file



516
517
518
519
520
521
522
523
524
# File 'app/concerns/models/publication.rb', line 516

def publication_download_url
  return unless is_publication?
  return if literature.blank?

  token = durable_download_token
  file_name = literature.attachment_name.presence
  path = [token.token, (ERB::Util.url_encode(file_name) if file_name)].compact.join('/')
  "https://#{API_HOSTNAME}/downloads/#{path}?download=true"
end

#publication_edge_cache_urls(previous_product_line_id: nil, previous_page_paths: []) ⇒ Array<String>

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;
  • tag-driven CMS landing pages that render documents inline, 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.

  • previous_page_paths (Array<String>) (defaults to: [])

    CMS landing pages whose placement
    tags were removed during the update, so those old pages are refreshed too.

Returns:

  • (Array<String>)

    deduped absolute URLs to purge



726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
# File 'app/concerns/models/publication.rb', line 726

def publication_edge_cache_urls(previous_product_line_id: nil, previous_page_paths: [])
  # The publication's OWN surfaces lead the list. Cloudflare purges 30 URLs per
  # request and the product fan-out below routinely runs to several hundred, so
  # anything behind it rides on every one of those batches succeeding.
  landing_page_paths = publication_landing_page_paths | Array(previous_page_paths)
  urls = []
  urls += SiteMap.where(path: landing_page_paths).map(&:url) if landing_page_paths.present?
  urls += PUBLIC_LOCALES.map { |loc| "#{WEB_URL}/#{loc}/publications/#{sku}.pdf" } if sku.present?

  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.uniq
end

#publication_landing_page_pathsArray<String>

CMS landing pages where this publication is placed by a for-…-page tag.
Use DigitalAsset's authoritative tag/path map because converting the tag
text back into a path is ambiguous once page IDs contain dashes.

Returns:

  • (Array<String>)

    canonical SiteMap paths, with a leading slash



796
797
798
799
# File 'app/concerns/models/publication.rb', line 796

def publication_landing_page_paths
  page_paths_by_tag = DigitalAsset.available_page_tags_with_paths
  tags.filter_map { |tag| page_paths_by_tag[tag] }.uniq
end

#publication_pdf_changed?Boolean

Whether the publication's PDF changed — either the literature association
was swapped or the attached file on it was replaced.

Returns:

  • (Boolean)


640
641
642
# File 'app/concerns/models/publication.rb', line 640

def publication_pdf_changed?
  saved_change_to_literature_id? || has_literature_changed?
end

#publication_sku_checkBoolean

Check that our publication is formatted with the version

Returns:

  • (Boolean)

    true when the SKU was (or already was) well-formed



371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
# File 'app/concerns/models/publication.rb', line 371

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_urlString?

Public www URL for this publication's page.

Returns:

  • (String, nil)

    nil when this isn't a publication



485
486
487
488
489
# File 'app/concerns/models/publication.rb', line 485

def publication_url
  return unless is_publication?

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

#publication_visible_to_public?Boolean

Whether the publication can be shown on the public site: it has an attached
file and is reachable in at least one service locale.

Returns:

  • (Boolean)


318
319
320
# File 'app/concerns/models/publication.rb', line 318

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

#publish_pdf_changed_eventvoid

This method returns an undefined value.

Publish Events::PublicationPdfChanged (after_commit) — kicks off async
markdown extraction by UploadMarkdownExtractionWorker.



647
648
649
650
651
652
# File 'app/concerns/models/publication.rb', line 647

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_eventvoid

This method returns an undefined value.

Publish Events::PublicationUpdated (after_commit) with enough context for
Publication::CachePurgeHandler to also refresh pages this publication just
moved AWAY from (previous product line, previous tag-driven CMS pages).



687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
# File 'app/concerns/models/publication.rb', line 687

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
  data[:previous_page_paths] = publication_previous_page_paths unless publication_previous_page_paths.nil?
  Rails.configuration.event_store.publish(
    Events::PublicationUpdated.new(data:),
    stream_name: "Publication-#{id}"
  )
ensure
  self.publication_previous_page_paths = nil
end

#purge_publication_cache(previous_product_line_id: nil, previous_page_paths: []) ⇒ Array<String>, Symbol

Purge every cached copy of this publication: the edge-cached pages that
surface it (publication_edge_cache_urls) AND the ImageKit CDN copy of its
cover, whose ?v= version parameter is inert — ImageKit keys its CDN on the
transformation, not on unknown query parameters, so a cover replaced in
place keeps serving the old bytes for its year-long s-maxage until purged.

Shared by Publication::CachePurgeHandler (automatic, off
Events::PublicationUpdated) and the CRM "Purge Cache" command (manual).

Parameters:

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

    OLD primary product line id on
    a reassignment, so its pages are refreshed too

  • previous_page_paths (Array<String>) (defaults to: [])

    CMS landing pages whose placement
    tags were removed during the update, so those old pages are refreshed too

Returns:

  • (Array<String>, Symbol)

    the purged URLs, or :disabled off prod/staging

Raises:

  • (RuntimeError)

    when ImageKit rejected the cover purge — Image#purge_cache
    swallows that error, and reporting a purge as done while the cover is still
    stale is the exact failure this path exists to remove. Raised only after the
    edge purge is enqueued, so the retry never costs the page invalidation.



778
779
780
781
782
783
784
785
786
787
788
789
# File 'app/concerns/models/publication.rb', line 778

def purge_publication_cache(previous_product_line_id: nil, previous_page_paths: [])
  return :disabled unless Cache::EdgeCacheUtility.edge_cache_enabled?

  cover_purge = primary_image&.purge_cache
  urls = publication_edge_cache_urls(previous_product_line_id:, previous_page_paths:)
  EdgeCacheWorker.perform_async('urls' => urls) if urls.present?
  if (cover_error = cover_purge&.dig(:error)).present?
    raise "ImageKit cover purge failed for image #{primary_image.id}: #{cover_error}"
  end

  urls
end

Typed links to related publications (language variants). Links are created
bidirectionally (RelatedPublication.create_bidirectional / .link_translation),
so related_publications yields every linked publication regardless of
which side of the link this Item sits on.

Returns:

See Also:



39
40
41
42
# File 'app/concerns/models/publication.rb', line 39

has_many :related_publication_links, class_name: 'RelatedPublication',
foreign_key: :publication_id,
dependent: :destroy,
inverse_of: :publication

All linked publications (any link type), reached through
related_publication_links. See the related_publication_links note.

Returns:

See Also:



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

has_many :related_publications, through: :related_publication_links

#retrieve_publications_search_textString?

The publication's extractable document text as layout-aware Markdown, via
docling (Upload#extract_markdown!). Replaces the former PDF::Reader path,
whose garbled/concatenated output the surrounding squish/squeeze cleanup
tried (and failed) to salvage; docling preserves spec tables and reading
order. Self-healing and cached on the literature upload, so this is cheap
after the first call. UploadMarkdownExtractionWorker normally populates
+search_text+ from this asynchronously; content_for_embedding calls it live
only as a last resort when that cache hasn't landed yet.

Returns:

  • (String, nil)

    the Markdown, or nil when unavailable/extraction fails



473
474
475
476
477
478
479
480
481
# File 'app/concerns/models/publication.rb', line 473

def retrieve_publications_search_text
  return unless is_publication?
  return unless literature&.attachment_stored?

  literature.extract_markdown!.presence
rescue DoclingClient::Error => e
  logger.error "Unable to extract pdf content for publication id #{id}: #{e.message}"
  nil
end

#revision_chainArray<Item>

This publication's full revision chain, oldest → newest, walked through
the real +items.successor_item_id+ succession pointers (written by
Publication::Reviser and the successor backfill). Falls back to the
legacy canonical-SKU pattern match for chains that predate the pointers —
the pattern match false-positives across SKU prefixes (canonical TZ-100
also matches TZ-1000-A), so it only ever runs for unlinked rows.

Returns:

  • (Array<Item>)

    chain including self, oldest first



238
239
240
241
242
243
# File 'app/concerns/models/publication.rb', line 238

def revision_chain
  chain = linked_revision_chain
  return chain if chain.size > 1

  legacy_sku_revision_chain
end

#secondary_product_category_must_not_be_publicationvoid

This method returns an undefined value.

Validation: the secondary product category must NOT be a publication category
(a publication belongs to exactly one publication tree, via its primary category).



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

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

#should_queue_embedding?Boolean

Only embed active, public publications

Returns:

  • (Boolean)


902
903
904
905
906
# File 'app/concerns/models/publication.rb', line 902

def should_queue_embedding?
  return false unless is_publication?

  super && !is_discontinued? && publication_visible_to_public?
end

Translation-type subset of related_publication_links. Deliberately has
NO dependent: — the parent association already owns the cleanup.

Returns:

See Also:



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

has_many :translated_publication_links, -> { translations },
class_name: 'RelatedPublication',
foreign_key: :publication_id,
inverse_of: :publication

#translated_publicationsActiveRecord::Relation<TranslatedPublication>

Publications linked as language-translation variants of this one
(subset of related_publications via translated_publication_links).

Returns:

  • (ActiveRecord::Relation<TranslatedPublication>)

See Also:



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

has_many :translated_publications, through: :translated_publication_links,
source: :related_publication

#translation_for_language(lang) ⇒ Item?

The already-linked translation variant for a language, if one exists —
used by the translate flow to warn before creating a duplicate. Matches by
language FAMILY ('fr' matches fr, fr-CA, fr-FR; 'es' matches es, es-MX).

Parameters:

  • lang (String)

    target language code (e.g. 'fr', 'es', 'fr-CA')

Returns:

  • (Item, nil)

    the linked variant publication



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

def translation_for_language(lang)
  family = lang.to_s.downcase.split('-').first
  return nil if family.blank?

  translated_publications.detect do |variant|
    Array(variant.publication_locales).any? { |l| l.to_s.downcase.split('-').first == family }
  end
end