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
-
#available_in_canada ⇒ Boolean
Whether the publication is available in Canadian locales.
-
#available_in_usa ⇒ Boolean
Whether the publication is available in the US locale.
-
#publication_previous_page_paths ⇒ Object
Returns the value of attribute publication_previous_page_paths.
-
#serve_in_locale ⇒ Object
Returns the value of attribute serve_in_locale.
Has many collapse
-
#incoming_related_publication_links ⇒ ActiveRecord::Relation<RelatedPublication>
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.
-
#item_embeddings ⇒ ActiveRecord::Relation<ContentEmbedding::ItemEmbedding>
Association to the partitioned embeddings table for publication AI search.
-
#related_publication_links ⇒ ActiveRecord::Relation<RelatedPublication>
Typed links to related publications (language variants).
-
#related_publications ⇒ ActiveRecord::Relation<RelatedPublication>
All linked publications (any link type), reached through
related_publication_links. -
#translated_publication_links ⇒ ActiveRecord::Relation<RelatedPublication>
Translation-type subset of
related_publication_links. -
#translated_publications ⇒ ActiveRecord::Relation<TranslatedPublication>
Publications linked as language-translation variants of this one (subset of
related_publicationsviatranslated_publication_links).
Class Method Summary collapse
-
.ai_search_publications ⇒ ActiveRecord::Relation<Models::Publication>
A relation of Models::Publications that are ai search publications.
-
.ai_search_warning? ⇒ Boolean
Check if the last AI search had issues (call from controller after running scope).
-
.clear_ai_search_warning! ⇒ void
Clear the AI search warning.
-
.cover_ratio_mismatch ⇒ ActiveRecord::Relation<Models::Publication>
A relation of Models::Publications that are cover ratio mismatch.
-
.embeddable_content_types ⇒ Array<Symbol>
Content types for publication embeddings.
-
.hybrid_search_publications ⇒ ActiveRecord::Relation<Models::Publication>
A relation of Models::Publications that are hybrid search publications.
-
.publications ⇒ ActiveRecord::Relation<Models::Publication>
A relation of Models::Publications that are publications.
-
.publications_for_online_portal ⇒ ActiveRecord::Relation<Models::Publication>
A relation of Models::Publications that are publications for online portal.
-
.publications_for_public ⇒ ActiveRecord::Relation<Models::Publication>
A relation of Models::Publications that are publications for public.
-
.publications_for_public_in_store ⇒ ActiveRecord::Relation<Models::Publication>
A relation of Models::Publications that are publications for public in store.
-
.publications_for_sales_portal ⇒ ActiveRecord::Relation<Models::Publication>
A relation of Models::Publications that are publications for sales portal.
-
.publications_for_support_portal ⇒ ActiveRecord::Relation<Models::Publication>
A relation of Models::Publications that are publications for support portal.
-
.with_publication_attached ⇒ ActiveRecord::Relation<Models::Publication>
A relation of Models::Publications that are with publication attached.
Instance Method Summary collapse
-
#alias_sku ⇒ void
Store the new sku in our alias chain.
-
#analyze_pdf_images!(force: false) ⇒ void
Queue vision analysis for this publication's PDF images.
-
#available_service_locales ⇒ Array<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.
-
#available_to_all_locales? ⇒ Boolean
Whether the publication is available in every service locale.
-
#check_redirection_path ⇒ void
Validation/normalization: parses
redirection_path, strips locale prefixes from its path, and re-canonicalizes it against the production host. -
#compose_name_with_languages ⇒ String?
Display name with the publication's languages appended, e.g.
-
#content_for_embedding(_content_type = :primary) ⇒ String?
Generate content for semantic search embedding Uses extracted PDF text plus metadata.
-
#cover_image_url(options = {}) ⇒ String?
Pulls the primary image or generates one from the PDF if needed.
-
#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.
-
#default_publication_logistics ⇒ void
Set sensible defaults for logistics, in case this publication has to ship.
-
#durable_download_token ⇒ DownloadToken
The long-lived token behind #publication_download_url, reused when one already exists.
-
#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.
-
#embeddable_locales ⇒ Array<String>
For publications in multiple languages, return all locales.
-
#embedding_content_changed? ⇒ Boolean
Publications with PDF changes should regenerate embeddings.
-
#fast_country_discontinue ⇒ void
Shortcut method when we deal with publications.
-
#file_name_for_download(file_extension = nil) ⇒ String?
Download file name for the attached literature, derived from the item name and id.
-
#find_durable_download_token ⇒ DownloadToken?
The longest-lived marketplace token, if any.
-
#friendly_locale_names ⇒ Array<String>
Human-readable language names for this publication's locales, deduped.
-
#generate_publication_name ⇒ void
Embeds languages in name.
-
#has_literature_changed? ⇒ Boolean
Whether the attached literature file itself changed in this save.
-
#latest_revision ⇒ Item
The newest revision in this publication's chain (may be self).
-
#legacy_sku_revision_chain ⇒ Array<Item>
Legacy revision listing by canonical-SKU pattern — kept ONLY as the fallback for rows the successor backfill hasn't linked yet.
-
#linked_revision_chain ⇒ Array<Item>
Walk predecessor (superceded_items) to the chain head, then successor_item to the tail.
-
#locale_for_embedding ⇒ String
Locale for embedding - uses publication_locales Publications can be in multiple languages, returns the first/primary.
-
#needs_vision_analysis? ⇒ Boolean
Check if vision analysis is needed for this publication.
-
#perform_country_discontinue(store_id, discontinue) ⇒ void
Discontinue (or re-activate) this publication's StoreItem in a single store, cascading the state change to its catalog items.
-
#product_category_must_be_publication ⇒ void
Validation: the primary product category must itself be a publication category.
-
#publication_available_locales ⇒ Array<Symbol>
Returns the locales in which the publication is available.
-
#publication_base_name_clean_of_language ⇒ void
Validation: the base name must not embed a language word — languages are auto-appended from publication_locales (see #compose_name_with_languages).
-
#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.
-
#publication_download_url ⇒ String?
A publicly reachable, direct-download URL for this publication's file, served from the api subdomain.
-
#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.
-
#publication_landing_page_paths ⇒ Array<String>
CMS landing pages where this publication is placed by a
for-…-pagetag. -
#publication_pdf_changed? ⇒ Boolean
Whether the publication's PDF changed — either the literature association was swapped or the attached file on it was replaced.
-
#publication_sku_check ⇒ Boolean
Check that our publication is formatted with the version.
-
#publication_url ⇒ String?
Public www URL for this publication's page.
-
#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.
-
#publish_pdf_changed_event ⇒ void
Publish Events::PublicationPdfChanged (after_commit) — kicks off async markdown extraction by UploadMarkdownExtractionWorker.
-
#publish_publication_updated_event ⇒ void
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).
-
#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. -
#retrieve_publications_search_text ⇒ String?
The publication's extractable document text as layout-aware Markdown, via docling (Upload#extract_markdown!).
-
#revision_chain ⇒ Array<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).
-
#secondary_product_category_must_not_be_publication ⇒ void
Validation: the secondary product category must NOT be a publication category (a publication belongs to exactly one publication tree, via its primary category).
-
#should_queue_embedding? ⇒ Boolean
Only embed active, public publications.
-
#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.
Methods included from HybridSearchable
ranked_ids, rrf_ranked_relation
Instance Attribute Details
#available_in_canada ⇒ Boolean
Whether the publication is available in Canadian locales
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_usa ⇒ Boolean
Whether the publication is available in the US locale.
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_paths ⇒ Object
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_locale ⇒ Object
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_publications ⇒ ActiveRecord::Relation<Models::Publication>
A relation of Models::Publications that are ai search publications. Active Record Scope
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). = ContentEmbedding.(query) unless 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 = .size vector_literal = "[#{.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)
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_mismatch ⇒ ActiveRecord::Relation<Models::Publication>
A relation of Models::Publications that are cover ratio mismatch. Active Record Scope
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_types ⇒ Array<Symbol>
Content types for publication embeddings
808 809 810 |
# File 'app/concerns/models/publication.rb', line 808 def self. [:primary] end |
.hybrid_search_publications ⇒ ActiveRecord::Relation<Models::Publication>
A relation of Models::Publications that are hybrid search publications. Active Record Scope
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) } |
.publications ⇒ ActiveRecord::Relation<Models::Publication>
A relation of Models::Publications that are publications. Active Record Scope
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_portal ⇒ ActiveRecord::Relation<Models::Publication>
A relation of Models::Publications that are publications for online portal. Active Record Scope
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_public ⇒ ActiveRecord::Relation<Models::Publication>
A relation of Models::Publications that are publications for public. Active Record Scope
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_store ⇒ ActiveRecord::Relation<Models::Publication>
A relation of Models::Publications that are publications for public in store. Active Record Scope
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_portal ⇒ ActiveRecord::Relation<Models::Publication>
A relation of Models::Publications that are publications for sales portal. Active Record Scope
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_portal ⇒ ActiveRecord::Relation<Models::Publication>
A relation of Models::Publications that are publications for support portal. Active Record Scope
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_attached ⇒ ActiveRecord::Relation<Models::Publication>
A relation of Models::Publications that are with publication attached. Active Record Scope
22 |
# File 'app/concerns/models/publication.rb', line 22 scope :with_publication_attached, -> { joins(:literature).includes(:literature) } |
Instance Method Details
#alias_sku ⇒ void
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
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_locales ⇒ Array<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.
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.
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_path ⇒ void
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_languages ⇒ String?
Display name with the publication's languages appended, e.g.
"TempZone Cable Manual (English, French)". Nil when no base name is set.
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
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_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.}" 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
585 586 587 588 589 590 591 592 593 594 |
# File 'app/concerns/models/publication.rb', line 585 def cover_image_url( = {}) = .dup.symbolize_keys image = primary_image image ||= create_primary_image_from_pdf if .delete(:create_if_missing) return unless image [:format] ||= 'jpeg' [:width] ||= 1200 if [:size].blank? image.image_url() 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
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..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_logistics ⇒ void
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_token ⇒ DownloadToken
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.
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?.
868 869 870 |
# File 'app/concerns/models/publication.rb', line 868 def is_publication? end |
#embeddable_locales ⇒ Array<String>
For publications in multiple languages, return all locales
919 920 921 922 923 924 |
# File 'app/concerns/models/publication.rb', line 919 def 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
873 874 875 876 877 878 879 880 881 882 |
# File 'app/concerns/models/publication.rb', line 873 def 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_discontinue ⇒ void
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.
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_token ⇒ DownloadToken?
Returns 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_names ⇒ Array<String>
Human-readable language names for this publication's locales, deduped.
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_name ⇒ void
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.
631 632 633 634 635 |
# File 'app/concerns/models/publication.rb', line 631 def has_literature_changed? return false unless literature literature. || literature. end |
#incoming_related_publication_links ⇒ ActiveRecord::Relation<RelatedPublication>
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.
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_embeddings ⇒ ActiveRecord::Relation<ContentEmbedding::ItemEmbedding>
Association to the partitioned embeddings table for publication AI search
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_revision ⇒ Item
The newest revision in this publication's chain (may be self).
247 248 249 |
# File 'app/concerns/models/publication.rb', line 247 def latest_revision revision_chain.last end |
#legacy_sku_revision_chain ⇒ Array<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).
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_chain ⇒ Array<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.
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_embedding ⇒ String
Locale for embedding - uses publication_locales
Publications can be in multiple languages, returns the first/primary
911 912 913 914 915 |
# File 'app/concerns/models/publication.rb', line 911 def 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
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&. 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.
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_publication ⇒ void
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_locales ⇒ Array<Symbol>
Returns the locales in which the publication is available
311 312 313 |
# File 'app/concerns/models/publication.rb', line 311 def publication_available_locales available_service_locales end |
#publication_base_name_clean_of_language ⇒ void
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.
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_url ⇒ String?
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.
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..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.
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_paths ⇒ Array<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.
796 797 798 799 |
# File 'app/concerns/models/publication.rb', line 796 def publication_landing_page_paths page_paths_by_tag = DigitalAsset. .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.
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_check ⇒ Boolean
Check that our publication is formatted with the version
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_url ⇒ String?
Public www URL for this publication's page.
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.
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_event ⇒ void
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_event ⇒ void
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).
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 |
#related_publication_links ⇒ ActiveRecord::Relation<RelatedPublication>
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.
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 |
#related_publications ⇒ ActiveRecord::Relation<RelatedPublication>
All linked publications (any link type), reached through
related_publication_links. See the related_publication_links note.
45 |
# File 'app/concerns/models/publication.rb', line 45 has_many :related_publications, through: :related_publication_links |
#retrieve_publications_search_text ⇒ String?
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.
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&. literature.extract_markdown!.presence rescue DoclingClient::Error => e logger.error "Unable to extract pdf content for publication id #{id}: #{e.}" nil end |
#revision_chain ⇒ Array<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.
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_publication ⇒ void
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
902 903 904 905 906 |
# File 'app/concerns/models/publication.rb', line 902 def return false unless is_publication? super && !is_discontinued? && publication_visible_to_public? end |
#translated_publication_links ⇒ ActiveRecord::Relation<RelatedPublication>
Translation-type subset of related_publication_links. Deliberately has
NO dependent: — the parent association already owns the cleanup.
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_publications ⇒ ActiveRecord::Relation<TranslatedPublication>
Publications linked as language-translation variants of this one
(subset of related_publications via translated_publication_links).
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).
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 |