Class: Article
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- Article
- Extended by:
- FriendlyId
- Includes:
- Memery, Models::Auditable, Models::CrossLinkable, Models::EdgeCachePurgeable, Models::Embeddable, Models::Lineage, Models::LiquidMethods, Models::SchemaMarkup, Models::Taggable, Models::Utilities::Html, PgSearch::Model
- Defined in:
- app/models/article.rb
Overview
== Schema Information
Table name: articles
Database name: primary
id :integer not null, primary key
applies_to_product_category_ids :integer default([]), is an Array
applies_to_product_line_ids :integer default([]), is an Array
archived_at :datetime
auto_sanitize_html :boolean default(TRUE), not null
auto_sanitize_urls :boolean default(TRUE), not null
breadcrumbs :string default([]), not null, is an Array
department :string
description :text
has_toc :boolean default(FALSE), not null
inline_js :text
link_audit_result :jsonb
meta_description :string
meta_keywords :string
objective :text
problem_code :string(255)
publish_on :datetime
published_at :datetime
reading_time_minutes :integer
requires_seo_check :boolean default(FALSE), not null
revised_at :datetime
sales :boolean default(TRUE), not null
schema_extracted_at :datetime
schema_markup :jsonb
search_text :text
search_text_tsv :tsvector
seo_report :jsonb
serial_number_high_range :integer
serial_number_low_range :integer
short_url :string
short_url_with_ref :string
slug :string
slug_custom :string
solution :text
sort_booster :integer default(0), not null
state :string(20) default("draft")
subject :string(255) not null
support :boolean default(FALSE), not null
time_required :integer
title :string
toc_selector :string
type :string
version :integer
warranty_labor :boolean
warranty_parts :boolean
word_count :integer
created_at :datetime
updated_at :datetime
active_revision_id :bigint
brafton_id :integer
creator_id :integer
draft_revision_id :bigint
original_author_id :integer
precursor_id :integer
preview_image_id :string
related_article_1_id :integer
related_article_2_id :integer
related_article_3_id :integer
related_article_4_id :integer
source_id :integer
successor_article_id :integer
updater_id :integer
user_id :integer
Indexes
idx_articles_type_state_id (type,state,id)
idx_source_id_type (source_id,type)
idx_state_type_publish_on (state,type,publish_on)
index_articles_on_active_revision_id (active_revision_id)
index_articles_on_draft_revision_id (draft_revision_id)
index_articles_on_published_at (published_at) USING brin
index_articles_on_revised_at (revised_at)
index_articles_on_search_text_tsv (search_text_tsv) USING gin
index_articles_on_successor_article_id (successor_article_id)
index_articles_on_type_and_slug_unique (type,slug) UNIQUE
Foreign Keys
fk_rails_... (id => articles.id)
fk_rails_... (related_article_1_id => articles.id) ON DELETE => nullify
fk_rails_... (related_article_2_id => articles.id) ON DELETE => nullify
fk_rails_... (related_article_3_id => articles.id) ON DELETE => nullify
fk_rails_... (related_article_4_id => articles.id)
fk_rails_... (source_id => sources.id)
fk_rails_... (successor_article_id => articles.id)
Direct Known Subclasses
ArticleFaq, ArticleProcedure, ArticleTechnical, ArticleTraining, Post
Constant Summary collapse
- UNREVIEWED_KNOWLEDGE_ROLE =
Unreviewed knowledge role.
'unreviewed'- KNOWLEDGE_ROLES =
Knowledge roles.
{ UNREVIEWED_KNOWLEDGE_ROLE => 'Unreviewed', 'answer_source' => 'Answer Source', 'internal_procedure' => 'Internal Procedure', 'operational_record' => 'Operational Record', 'excluded' => 'Excluded' }.freeze
- KNOWLEDGE_CORPUS_ROLES =
Knowledge corpus roles.
[UNREVIEWED_KNOWLEDGE_ROLE, 'answer_source', 'internal_procedure'].freeze
- EMBEDDABLE_STATES =
Embeddable states.
%w[published internal].freeze
- FAQ_MAX_WORDS =
Faq max words.
150- FAQ_TARGET_WORDS =
Faq target words.
(40..60)
- FAQ_WARN_WORDS =
Faq warn words.
100- FAQ_WORD_COUNT_SQL =
FAQ audit scopes — used by the article_faqs index stats cards and filters.
Word count is approximated by stripping HTML tags and splitting on whitespace. "array_length(regexp_split_to_array(trim(regexp_replace(COALESCE(articles.solution,''), '<[^>]*>', ' ', 'g')), '\\s+'), 1)"- STI_TYPES =
Keep in sync with Article subclasses: Post, ArticleFaq, ArticleTechnical,
ArticleTraining, ArticleProcedure. Used by types_options, convert_type, and search filters. { 'Post' => 'Blog Post', 'ArticleFaq' => 'FAQ', 'ArticleTechnical' => 'Technical', 'ArticleTraining' => 'Training', 'ArticleProcedure' => 'Procedure' }.freeze
Constants included from Models::Embeddable
Models::Embeddable::MAX_CONTENT_LENGTH
Constants included from Models::Auditable
Models::Auditable::ALWAYS_IGNORED
Constants included from Models::Schedulable
Models::Schedulable::SIMPLE_FORM_OPTIONS
Instance Attribute Summary collapse
-
#preserve_revision_slug ⇒ Object
Returns the value of attribute preserve_revision_slug.
-
#schema_markup ⇒ Object
readonly
Validates schema markup.
-
#serial_number_high_range ⇒ Object
readonly
Validates serial number high range, serial number low range.
-
#serial_number_low_range ⇒ Object
readonly
Validates serial number low range, serial number high range.
-
#subject ⇒ Object
readonly
Validates subject.
Belongs to collapse
-
#original_author ⇒ Employee?
The original author this record belongs to.
-
#precursor ⇒ Article?
The precursor this record belongs to.
-
#preview_image ⇒ Image?
The preview image this record belongs to.
-
#rma_reason_code ⇒ RmaReasonCode?
The rma reason code this record belongs to.
-
#successor_article ⇒ Article?
The successor article this record belongs to.
-
#user ⇒ Employee
The current author.
Methods included from Models::Auditable
Has many collapse
-
#article_pages ⇒ ActiveRecord::Relation<ArticlePage>
The associated article pages.
-
#embedded_assets ⇒ ActiveRecord::Relation<EmbeddedAsset>
The associated embedded assets.
-
#item_product_lines ⇒ ActiveRecord::Relation<ProductLine>
The associated item product lines.
-
#post_comments ⇒ ActiveRecord::Relation<PostComment>
The associated post comments.
-
#revisions ⇒ ActiveRecord::Relation<ArticleRevision>
============================================================================= REVISION SYSTEM ============================================================================= Article content revisions are shared by every Article STI subtype.
-
#site_maps ⇒ ActiveRecord::Relation<SiteMap>
The associated site maps.
-
#successors ⇒ ActiveRecord::Relation<Article>
The associated successors.
-
#votes ⇒ ActiveRecord::Relation<Vote>
The associated votes.
Methods included from Models::CrossLinkable
#inbound_content_links, #outbound_content_links
Methods included from Models::Taggable
Methods included from Models::Embeddable
Has and belongs to many collapse
-
#items ⇒ ActiveRecord::Relation<Item>
The associated items.
-
#link_checks ⇒ ActiveRecord::Relation<LinkCheck>
The associated link checks.
-
#product_categories ⇒ ActiveRecord::Relation<ProductCategory>
The associated product categories.
-
#product_lines ⇒ ActiveRecord::Relation<ProductLine>
The associated product lines.
-
#topics ⇒ ActiveRecord::Relation<Topic>
The associated topics.
-
#uploads ⇒ ActiveRecord::Relation<Upload>
The associated uploads.
Class Method Summary collapse
-
.all_product_lines ⇒ Object
All product lines.
-
.archived ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are archived.
-
.available_schema_types ⇒ Object
Available schema types.
-
.delete_content_embeddings_for(article_id, embedding_types:) ⇒ Integer
Deletes Article embedding rows from the requested concrete storage partitions.
-
.drafts ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are drafts.
-
.due_for_publishing ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are due for publishing.
-
.embeddable_content_types ⇒ Object
Embeddable configuration.
-
.embedded_in_posts ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are embedded in posts.
-
.embedding_eligible ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are embedding eligible.
-
.faqs ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are faqs.
-
.for_item_sku ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are for item sku.
-
.for_product_category_url_without_order ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are for product category url without order.
-
.for_product_line_url ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are for product line url.
-
.for_product_line_url_with_ancestors ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are for product line url with ancestors.
-
.for_product_line_url_with_descendants ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are for product line url with descendants.
-
.for_product_line_url_without_order ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are for product line url without order.
-
.for_product_line_urls_with_descendants ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are for product line urls with descendants.
-
.for_sales_portal ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are for sales portal.
-
.for_support_portal ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are for support portal.
-
.has_comments ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are has comments.
-
.has_schema ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are has schema.
-
.has_video ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are has video.
-
.knowledge_corpus ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are knowledge corpus.
-
.knowledge_roles_for_select ⇒ Object
Knowledge roles for select.
-
.most_recent_first ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are most recent first.
-
.no_schema ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are no schema.
-
.not_for_public ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are not for public.
-
.options_for_select ⇒ Array<Array>
[label, id]pairs for select dropdowns. -
.orphan_faqs ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are orphan faqs.
-
.over_word_limit ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are over word limit.
-
.pending_review ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are pending review.
-
.press_industry_reports ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are press industry reports.
-
.press_releases ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are press releases.
-
.procedure_types ⇒ Object
Procedure types.
-
.publish_posts_due_for_publishing(logger = Rails.logger) ⇒ Object
Publish posts due for publishing.
-
.published ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are published.
-
.ransackable_scopes(_auth_object = nil) ⇒ Array<Symbol>
Ransack-allowlisted scopes.
-
.reading_time_options ⇒ Object
Options for the reading time dropdown (1-30 minutes).
-
.schema_types_in ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are schema types in.
-
.slug_find(slug) ⇒ Object
Slug find.
-
.sorted_by_most_recent_first ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are sorted by most recent first.
-
.sorted_with_booster ⇒ Object
Sorted with booster.
-
.states_for_select ⇒ Object
States for select.
-
.types_options ⇒ Object
Types options.
-
.types_options_for_search ⇒ Object
Types options for search.
-
.waiting_to_publish ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are waiting to publish.
-
.with_votes ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are with votes.
Instance Method Summary collapse
-
#activate_reviewed_successor ⇒ Object
Activate reviewed successor.
-
#applicable_product_category_urls ⇒ Object
Applicable product category urls.
-
#applicable_product_line_urls ⇒ Object
============================================================ End Revision System Methods ============================================================.
-
#article_type_human ⇒ Object
Article type human.
-
#author ⇒ Object
Author.
-
#breadcrumbs_hash ⇒ Object
Breadcrumbs hash.
-
#build_revision_draft(author:, source_revision: nil) ⇒ Object
Build revision draft.
-
#clear_state_transition_validation ⇒ Object
Clear state transition validation.
-
#content_for_embedding(_content_type = :primary) ⇒ Object
Content for embedding.
-
#create_revision_draft!(author:, source_revision: nil, attributes: {}) ⇒ Object
Create revision draft, raising on failure.
-
#effective_published_date ⇒ Object
Effective published date.
-
#effective_revised_date ⇒ Object
Effective revised date.
-
#email_url ⇒ Object
Email url.
-
#embedding_eligible? ⇒ Boolean
Whether the record embedding eligible.
-
#embedding_type_name ⇒ Object
STI subtypes (ArticleFaq, ArticleTechnical, etc.) must use the base "Article" type for content_embeddings partitioning and validation.
-
#events_for_select ⇒ Object
Events for select.
-
#faq_word_count ⇒ Object
Faq word count.
-
#file_name ⇒ Object
File name.
-
#full_localized_solution(locale = nil, preview_mode: false) ⇒ Object
Full localized solution.
-
#generate_pdf ⇒ Object
Generate pdf.
-
#get_img_src_urls ⇒ Object
Get img src urls.
-
#knowledge_corpus? ⇒ Boolean
Whether the record knowledge corpus.
-
#knowledge_role_human ⇒ Object
Knowledge role human.
-
#latest_revision ⇒ Object
Latest revision.
-
#liquid_variable_context(_locale, _preview_mode) ⇒ Object
Override in subclasses to inject model-specific variables into the Liquid render context for +localized_solution+.
-
#localize_price(raw_html) ⇒ Object
Localize price.
-
#localized_solution(locale = nil, preview_mode: false, absolute_urls: false) ⇒ String?
Produce the article solution HTML localized for a given locale, with optional product embed processing and optional rewriting of relative URLs to absolute URLs.
-
#localized_solution_html_doc(preview_mode: false) ⇒ Object
Localized solution html doc.
-
#normalize_friendly_id(value) ⇒ Object
Normalize friendly id.
-
#ok_to_delete? ⇒ Boolean
Placeholder for can can ability.
-
#open_revision ⇒ Object
Open revision.
-
#page_title ⇒ Object
Page title.
-
#possible_events ⇒ Object
Possible events.
-
#possible_events_for_select ⇒ Object
Possible events for select.
-
#prepare_state_transition_validation(_event) ⇒ Object
Prepare state transition validation.
-
#primary_site_map ⇒ SiteMap?
Returns the primary site_map for this article (US locale preferred).
-
#procedure? ⇒ Boolean
Whether the record procedure.
-
#process_product_embeds(html, locale) ⇒ String
Process product embeds in HTML to render locale-aware content with working Add to Cart buttons This ensures products are rendered fresh with current pricing and functional buttons.
-
#purge_edge_cache(include_indirect_associations: true, extra_urls: []) ⇒ Object
Purge edge cache.
-
#reading_time_in_minutes ⇒ Object
Returns reading time in minutes, calculating on the fly if not stored.
-
#related_articles_for_display ⇒ Object
Get data for partial views/posts/_related_post Returns articles in slot order (1→4), skipping nil/unpublished.
-
#related_articles_for_select ⇒ Object
Related articles for select.
-
#requires_editorial_review? ⇒ Boolean
Whether the record requires editorial review.
-
#revision_article_data ⇒ Object
Revision article data.
-
#revision_content_attributes ⇒ Object
Revision content attributes.
-
#revision_content_digest ⇒ Object
Revision content digest.
-
#revision_content_link_data ⇒ Array<Hash>
Returns ordinary related materials as revision-safe proposal rows.
-
#revision_page_attributes ⇒ Object
Revision page attributes.
-
#revision_proposable? ⇒ Boolean
Whether the record revision proposable.
-
#sanitize_urls ⇒ Object
Sanitize urls.
-
#schema_dot_org_images ⇒ Object
Schema dot org images.
-
#schema_dot_org_structure ⇒ Object
Schema dot org structure.
-
#schema_dot_org_structure_faq ⇒ Object
Schema dot org structure faq.
-
#send_creation_email ⇒ Object
Send creation email.
-
#send_update_email ⇒ Object
Send update email.
-
#seo_clicks ⇒ Integer?
GSC clicks (28 days) - verified Google search traffic.
-
#seo_keywords_count ⇒ Integer?
Keywords count from Ahrefs.
-
#seo_last_synced_at ⇒ DateTime?
Last sync timestamp for SEO metrics.
-
#seo_metrics_synced? ⇒ Boolean
Check if SEO metrics are available.
-
#seo_top_keyword ⇒ String?
Top keyword from Ahrefs.
-
#seo_top_position ⇒ Integer?
Top position from Ahrefs.
-
#seo_traffic ⇒ Integer?
Ahrefs estimated organic traffic.
-
#seo_traffic_value ⇒ Integer?
Traffic value from Ahrefs.
-
#should_generate_new_friendly_id? ⇒ Boolean
Whether the record should generate new friendly id.
-
#slug_candidates ⇒ Object
Slug candidates.
-
#snapshot_revision!(change_notes: nil, author_id: nil) ⇒ Object
Creates an immutable snapshot of the article's current content fields.
-
#solution_text ⇒ Object
Solution text.
-
#subject_with_type ⇒ Object
Subject with type.
-
#to_json_ld ⇒ Object
To json ld.
-
#to_json_raw ⇒ Object
To json raw.
-
#uses_revisions? ⇒ Boolean
============================================================ Revision System Methods ============================================================.
-
#visit_count_30d ⇒ Integer?
First-party visit count (30 days) - most reliable traffic metric.
Methods included from Models::EdgeCachePurgeable
#edge_cache_urls, #enqueue_edge_cache_purge
Methods included from Models::CrossLinkable
#case_evidence, #content_links_count, #linked_articles, #linked_content, #linked_faqs, #linked_posts, #linked_publications, #linked_showcases, #linked_videos
Methods included from Models::Taggable
#add_tag, all_tags, #has_tag?, normalize_tag_names, not_tagged_with, #remove_tag, #tag_list, #tag_list=, #taggable_type_for_tagging, tagged_with, #tags, #tags=, tags_cloud, tags_exclude, tags_include, with_all_tags, with_any_tags, without_all_tags, without_any_tags
Methods included from Models::Embeddable
#embeddable_locales, #embedding_content_hash, #embedding_current?, embedding_partition_class, #embedding_stale?, #embedding_vector, #find_content_embedding, #find_similar, #generate_all_embeddings!, #generate_chunked_embeddings!, #generate_embedding!, #has_embedding?, #locale_for_embedding, #needs_chunking?, regenerate_all_embeddings, semantic_search, with_shape_lock
Methods included from Models::SchemaMarkup
#add_schema, #clear_schema_markup, #consolidated_faq_page_schema, #content_has_embedded_faq_schema?, #embedded_faq_ids_from_content, #has_schema_type?, #render_schema_markup, #schema_count, #schema_types, #schemas_by_type
Methods included from Models::Auditable
#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record
Methods included from Models::Lineage
#ancestors, #ancestors_ids, #children_and_roots, #descendants, #descendants_ids, #ensure_non_recursive_lineage, #family_members, #generate_full_name, #generate_full_name_array, #lineage, #lineage_array, #lineage_simple, #root, #root_id, #self_ancestors_and_descendants, #self_ancestors_and_descendants_ids, #self_and_ancestors, #self_and_ancestors_ids, #self_and_children, #self_and_descendants, #self_and_descendants_ids, #self_and_siblings, #self_and_siblings_ids, #siblings, #siblings_ids
Methods inherited from ApplicationRecord
ransackable_associations, ransackable_attributes, ransortable_attributes, #to_relation
Methods included from Models::Schedulable
Methods included from Models::AfterCommittable
Methods included from Models::EventPublishable
Instance Attribute Details
#preserve_revision_slug ⇒ Object
Returns the value of attribute preserve_revision_slug.
98 99 100 |
# File 'app/models/article.rb', line 98 def preserve_revision_slug @preserve_revision_slug end |
#schema_markup ⇒ Object (readonly)
Validates schema markup.
Validations:
376 |
# File 'app/models/article.rb', line 376 validates :schema_markup, presence: false |
#serial_number_high_range ⇒ Object (readonly)
Validates serial number high range, serial number low range.
Validations (if => -> { serial_number_high_range.present? and serial_number_low_range.present? } ):
- Numericality ({ only_integer: true, greater_than: :serial_number_low_range })
372 |
# File 'app/models/article.rb', line 372 validates :serial_number_high_range, numericality: { only_integer: true, greater_than: :serial_number_low_range }, if: -> { serial_number_high_range.present? and serial_number_low_range.present? } |
#serial_number_low_range ⇒ Object (readonly)
Validates serial number low range, serial number high range.
Validations (if => -> { serial_number_low_range.present? and serial_number_high_range.present? } ):
- Numericality ({ only_integer: true, less_than: :serial_number_high_range })
370 |
# File 'app/models/article.rb', line 370 validates :serial_number_low_range, numericality: { only_integer: true, less_than: :serial_number_high_range }, if: -> { serial_number_low_range.present? and serial_number_high_range.present? } |
#subject ⇒ Object (readonly)
Validates subject.
Validations:
374 |
# File 'app/models/article.rb', line 374 validates :subject, presence: true |
Class Method Details
.all_product_lines ⇒ Object
All product lines.
831 |
# File 'app/models/article.rb', line 831 def self.all_product_lines; end |
.archived ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are archived. Active Record Scope
400 |
# File 'app/models/article.rb', line 400 scope :archived, -> { where(state: 'archived') } |
.available_schema_types ⇒ Object
Available schema types.
844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 |
# File 'app/models/article.rb', line 844 def self.available_schema_types Article.where.not(schema_markup: [nil, 'null', '', []]) .pluck(:schema_markup) .compact .flat_map do |schemas| # If already parsed, use as is; otherwise, parse JSON schemas = JSON.parse(schemas) if schemas.is_a?(String) Array(schemas).map { |s| s['@type'] } rescue JSON::ParserError, TypeError [] end .compact .uniq .sort end |
.delete_content_embeddings_for(article_id, embedding_types:) ⇒ Integer
Deletes Article embedding rows from the requested concrete storage
partitions. Type conversion and lifecycle synchronization share this path
so partition cleanup cannot drift between callers.
140 141 142 143 144 145 |
# File 'app/models/article.rb', line 140 def self.(article_id, embedding_types:) ContentEmbedding.where( embeddable_type: Array().flatten.compact.uniq, embeddable_id: article_id ).delete_all end |
.drafts ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are drafts. Active Record Scope
395 |
# File 'app/models/article.rb', line 395 scope :drafts, -> { where(state: 'draft') } |
.due_for_publishing ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are due for publishing. Active Record Scope
413 |
# File 'app/models/article.rb', line 413 scope :due_for_publishing, -> { waiting_to_publish.where(Article[:publish_on].lteq(Time.current)) } |
.embeddable_content_types ⇒ Object
Embeddable configuration
1380 1381 1382 |
# File 'app/models/article.rb', line 1380 def self. [:primary] end |
.embedded_in_posts ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are embedded in posts. Active Record Scope
287 288 289 290 291 292 293 294 295 |
# File 'app/models/article.rb', line 287 scope :embedded_in_posts, ->(value = 'true') { case value when 'true' faqs.where("EXISTS (SELECT 1 FROM embedded_assets ea WHERE ea.asset_id = articles.id AND ea.asset_type = 'Article' AND ea.type = 'EmbeddedFaqAsset')") when 'false' faqs.where("NOT EXISTS (SELECT 1 FROM embedded_assets ea WHERE ea.asset_id = articles.id AND ea.asset_type = 'Article' AND ea.type = 'EmbeddedFaqAsset')") else all end } |
.embedding_eligible ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are embedding eligible. Active Record Scope
402 403 404 405 406 407 408 409 410 411 412 |
# File 'app/models/article.rb', line 402 scope :embedding_eligible, lambda { table = arel_table post_type = Post.sti_name published_post = table[:type].eq(post_type).and(table[:state].eq('published')) non_post = table[:type].not_eq(post_type).or(table[:type].eq(nil)) corpus_article = non_post .and(table[:state].in(EMBEDDABLE_STATES)) .and(table[:knowledge_role].in(KNOWLEDGE_CORPUS_ROLES)) where(published_post.or(corpus_article)) } |
.faqs ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are faqs. Active Record Scope
229 |
# File 'app/models/article.rb', line 229 scope :faqs, -> { where(type: 'ArticleFaq') } |
.for_item_sku ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are for item sku. Active Record Scope
245 |
# File 'app/models/article.rb', line 245 scope :for_item_sku, ->(sku) { joins(:items).where(items: { sku: sku }) } |
.for_product_category_url_without_order ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are for product category url without order. Active Record Scope
240 |
# File 'app/models/article.rb', line 240 scope :for_product_category_url_without_order, ->(pc_url) { where.overlap(applies_to_product_category_ids: ProductCategory.where(url: pc_url).ids.compact) } |
.for_product_line_url ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are for product line url. Active Record Scope
241 |
# File 'app/models/article.rb', line 241 scope :for_product_line_url, ->(pl_url) { for_product_line_url_without_order(pl_url).sorted_by_most_recent_first } |
.for_product_line_url_with_ancestors ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are for product line url with ancestors. Active Record Scope
419 420 421 422 423 |
# File 'app/models/article.rb', line 419 scope :for_product_line_url_with_ancestors, ->(url) { slug = LtreePaths.slug_ltree_from_legacy_hyphen_url(url) || url plids = ProductLine.by_url_with_ancestors(slug).ids where('exists(select 1 from articles_product_lines apl where apl.article_id = articles.id and apl.product_line_id IN (?))', plids) } |
.for_product_line_url_with_descendants ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are for product line url with descendants. Active Record Scope
414 415 416 417 418 |
# File 'app/models/article.rb', line 414 scope :for_product_line_url_with_descendants, ->(url) { slug = LtreePaths.slug_ltree_from_legacy_hyphen_url(url) || url plids = ProductLine.by_url_with_descendants(slug).ids where('exists(select 1 from articles_product_lines apl where apl.article_id = articles.id and apl.product_line_id IN (?))', plids) } |
.for_product_line_url_without_order ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are for product line url without order. Active Record Scope
232 233 234 235 236 237 238 239 |
# File 'app/models/article.rb', line 232 scope :for_product_line_url_without_order, ->(pl_url) { if pl_url.blank? none else slug = LtreePaths.slug_ltree_from_legacy_hyphen_url(pl_url) || pl_url where.overlap(applies_to_product_line_ids: ProductLine.where(slug_ltree: slug).ids.compact) end } |
.for_product_line_urls_with_descendants ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are for product line urls with descendants. Active Record Scope
242 |
# File 'app/models/article.rb', line 242 scope :for_product_line_urls_with_descendants, ->(pl_urls) { for_product_line_url_with_descendants(pl_urls) } |
.for_sales_portal ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are for sales portal. Active Record Scope
243 |
# File 'app/models/article.rb', line 243 scope :for_sales_portal, -> { where(sales: true) } |
.for_support_portal ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are for support portal. Active Record Scope
244 |
# File 'app/models/article.rb', line 244 scope :for_support_portal, -> { where(support: true) } |
.has_comments ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are has comments. Active Record Scope
321 322 323 324 325 326 327 328 329 330 |
# File 'app/models/article.rb', line 321 scope :has_comments, ->(value) { case value when 'true' joins(:post_comments).distinct when 'false' where.missing(:post_comments) else all end } |
.has_schema ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are has schema. Active Record Scope
309 310 311 312 313 314 315 316 317 318 |
# File 'app/models/article.rb', line 309 scope :has_schema, ->(value) { case value when 'true' where.not("schema_markup IS NULL OR schema_markup = '[]' OR schema_markup = '{}'") when 'false' where("schema_markup IS NULL OR schema_markup = '[]' OR schema_markup = '{}'") else all end } |
.has_video ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are has video. Active Record Scope
342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 |
# File 'app/models/article.rb', line 342 scope :has_video, ->(value) { patterns = [ # New oEmbed pattern (Redactor 4) '%data-wy-video-id%', # Legacy Liquid tag '%{{ cloudflare_video%', # HTML5 video '%<video%', # Cloudflare '%cloudflarestream.com%', # YouTube '%youtube.com/embed%', '%youtu.be%', # Vimeo '%player.vimeo.com%' ] clause = patterns.map { |pattern| Article[:solution].matches(pattern) }.reduce(:or) case value when 'true' where(clause) when 'false' where.not(clause) else all end } |
.knowledge_corpus ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are knowledge corpus. Active Record Scope
401 |
# File 'app/models/article.rb', line 401 scope :knowledge_corpus, -> { where(knowledge_role: KNOWLEDGE_CORPUS_ROLES) } |
.knowledge_roles_for_select ⇒ Object
Knowledge roles for select.
129 130 131 |
# File 'app/models/article.rb', line 129 def self.knowledge_roles_for_select KNOWLEDGE_ROLES.map { |value, label| [label, value] } end |
.most_recent_first ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are most recent first. Active Record Scope
249 |
# File 'app/models/article.rb', line 249 scope :most_recent_first, -> { order(Arel.sql('COALESCE(articles.revised_at, articles.published_at, articles.created_at) DESC')) } |
.no_schema ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are no schema. Active Record Scope
306 |
# File 'app/models/article.rb', line 306 scope :no_schema, -> { where("schema_markup IS NULL OR schema_markup = '[]' OR schema_markup = '{}'") } |
.not_for_public ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are not for public. Active Record Scope
399 |
# File 'app/models/article.rb', line 399 scope :not_for_public, -> { where(state: 'not_for_public') } |
.options_for_select ⇒ Array<Array>
Returns [label, id] pairs for select dropdowns.
871 872 873 |
# File 'app/models/article.rb', line 871 def self. Article.all.map { |a| ["#{a.id} - #{a.subject}", a.id] } end |
.orphan_faqs ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are orphan faqs. Active Record Scope
278 279 280 281 282 283 284 285 286 |
# File 'app/models/article.rb', line 278 scope :orphan_faqs, ->(value = 'true') { return all unless value == 'true' faqs .where(sales: false, support: false) .where.missing(:product_lines) .where("NOT EXISTS (SELECT 1 FROM taggings t WHERE t.taggable_id = articles.id AND t.taggable_type IN ('Article', 'ArticleFaq'))") .where("NOT EXISTS (SELECT 1 FROM embedded_assets ea WHERE ea.asset_id = articles.id AND ea.asset_type = 'Article' AND ea.type = 'EmbeddedFaqAsset')") } |
.over_word_limit ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are over word limit. Active Record Scope
271 272 273 274 275 276 277 |
# File 'app/models/article.rb', line 271 scope :over_word_limit, ->(value = 'true') { case value when 'true' then faqs.where("#{FAQ_WORD_COUNT_SQL} > #{FAQ_MAX_WORDS}") when 'false' then faqs.where("#{FAQ_WORD_COUNT_SQL} <= #{FAQ_MAX_WORDS} OR articles.solution IS NULL") else all end } |
.pending_review ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are pending review. Active Record Scope
396 |
# File 'app/models/article.rb', line 396 scope :pending_review, -> { where(state: 'pending_review') } |
.press_industry_reports ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are press industry reports. Active Record Scope
248 |
# File 'app/models/article.rb', line 248 scope :press_industry_reports, -> { tagged_with('press-industry-report') } |
.press_releases ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are press releases. Active Record Scope
247 |
# File 'app/models/article.rb', line 247 scope :press_releases, -> { tagged_with('press-release') } |
.procedure_types ⇒ Object
Procedure types.
839 840 841 |
# File 'app/models/article.rb', line 839 def self.procedure_types ArticleProcedure::DEPARTMENTS.map { |d| [d, d] }.sort end |
.publish_posts_due_for_publishing(logger = Rails.logger) ⇒ Object
Publish posts due for publishing.
756 757 758 759 760 761 762 |
# File 'app/models/article.rb', line 756 def self.publish_posts_due_for_publishing(logger = Rails.logger) logger.info "* Article#publish_posts_due_for_publishing, date/time: #{Time.current}, posts to process: #{Post.due_for_publishing.count}" Article.due_for_publishing.each do |article| logger.info "* Article#publish_posts_due_for_publishing, date/time: #{Time.current}, publishing post: #{article.subject}" article.publish! end end |
.published ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are published. Active Record Scope
398 |
# File 'app/models/article.rb', line 398 scope :published, -> { where(state: 'published') } |
.ransackable_scopes(_auth_object = nil) ⇒ Array<Symbol>
Returns Ransack-allowlisted scopes.
620 621 622 623 |
# File 'app/models/article.rb', line 620 def self.ransackable_scopes(_auth_object = nil) %i[search tags_include schema_types_in no_schema has_schema has_comments has_video over_word_limit orphan_faqs embedded_in_posts] end |
.reading_time_options ⇒ Object
Options for the reading time dropdown (1-30 minutes)
1639 1640 1641 |
# File 'app/models/article.rb', line 1639 def self. (1..30).map { |n| ["#{n} min", n] } end |
.schema_types_in ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are schema types in. Active Record Scope
298 299 300 301 302 303 |
# File 'app/models/article.rb', line 298 scope :schema_types_in, ->(*schema_types) { schema_types = schema_types.flatten.filter_map(&:presence).uniq return none if schema_types.empty? where("EXISTS (SELECT 1 FROM jsonb_array_elements(schema_markup) AS schemas WHERE schemas->>'@type' = ANY(ARRAY[#{schema_types.map { |v| "'#{v}'" }.join(',')}]))") } |
.slug_find(slug) ⇒ Object
Slug find.
804 805 806 807 808 |
# File 'app/models/article.rb', line 804 def self.slug_find(slug) return if slug.blank? friendly.find(slug) end |
.sorted_by_most_recent_first ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are sorted by most recent first. Active Record Scope
230 |
# File 'app/models/article.rb', line 230 scope :sorted_by_most_recent_first, -> { order(Article[:created_at].desc) } |
.sorted_with_booster ⇒ Object
Sorted with booster.
511 512 513 514 515 516 517 518 519 520 521 522 523 |
# File 'app/models/article.rb', line 511 def self.sorted_with_booster # Convert the result of `with_votes` to an array and sort it. with_votes.to_a.sort_by do |a| [ # Sort by negative of the sum of positive votes and sort booster to get descending order. -(a.positive_votes.to_i + a.sort_booster.to_i), # Sort by negative of the sum of vote count and sort booster for descending order. -(a.vote_count.to_i + a.sort_booster.to_i), # Sort by subject alphabetically. a.subject ] end.uniq # Ensure the list is unique. end |
.states_for_select ⇒ Object
States for select.
720 721 722 723 724 |
# File 'app/models/article.rb', line 720 def self.states_for_select states = state_machines[:state].states states = states.reject { |state| state.name == :pending_review } unless self <= ArticleTechnical states.map { |state| [state.human_name.titleize, state.value] } end |
.types_options ⇒ Object
Types options.
886 887 888 |
# File 'app/models/article.rb', line 886 def self. STI_TYPES.map { |type, label| [label, type] }.sort end |
.types_options_for_search ⇒ Object
Types options for search.
891 892 893 |
# File 'app/models/article.rb', line 891 def self. STI_TYPES.map { |type, label| [label, type] }.sort end |
.waiting_to_publish ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are waiting to publish. Active Record Scope
397 |
# File 'app/models/article.rb', line 397 scope :waiting_to_publish, -> { where(state: 'scheduled') } |
.with_votes ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are with votes. Active Record Scope
250 251 252 |
# File 'app/models/article.rb', line 250 scope :with_votes, -> { left_joins(:votes).select_append(Arel.sql('count(votes.id) as vote_count, COALESCE(SUM((votes.value > 0)::int), 0) as positive_votes')).order(Arel.sql('COALESCE(SUM((votes.value > 0)::int), 0) desc, count(votes.id)')).group(:id) } |
Instance Method Details
#activate_reviewed_successor ⇒ Object
Activate reviewed successor.
604 605 606 |
# File 'app/models/article.rb', line 604 def activate_reviewed_successor true end |
#applicable_product_category_urls ⇒ Object
Applicable product category urls.
1363 1364 1365 |
# File 'app/models/article.rb', line 1363 def applicable_product_category_urls ProductCategory.where(id: applies_to_product_category_ids).order(:url).pluck(:url) end |
#applicable_product_line_urls ⇒ Object
1358 1359 1360 |
# File 'app/models/article.rb', line 1358 def applicable_product_line_urls ProductLine.where(id: applies_to_product_line_ids).order(:slug_ltree).pluck(:slug_ltree) end |
#article_pages ⇒ ActiveRecord::Relation<ArticlePage>
Returns the associated article pages.
197 |
# File 'app/models/article.rb', line 197 has_many :article_pages, dependent: :destroy |
#article_type_human ⇒ Object
Article type human.
821 822 823 |
# File 'app/models/article.rb', line 821 def article_type_human self.class.name.delete_prefix('Article').presence || 'Article' end |
#author ⇒ Object
Author.
798 799 800 |
# File 'app/models/article.rb', line 798 def || creator || updater end |
#breadcrumbs_hash ⇒ Object
Breadcrumbs hash.
526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 |
# File 'app/models/article.rb', line 526 def bc = [] .each do |path_string| path_parts = path_string.split('@') path_url = nil path_name = nil if path_parts.size == 2 path_name, path_url = path_parts elsif path_parts.size == 1 path_url = path_string path_name = path_string.split('/').last.scan(/\w+/).join(' ').humanize end bc << { url: "/#{I18n.locale}/#{path_url}".squeeze('/'), name: path_name } # `StandardException` is not a Ruby class — the rescue never matched, so any # breadcrumb-formatting failure raised NameError out of the handler instead # of being logged and swallowed. rescue StandardError Rails.logger.error 'Error in breadcrumb path formatting' end bc end |
#build_revision_draft(author:, source_revision: nil) ⇒ Object
Build revision draft.
1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 |
# File 'app/models/article.rb', line 1239 def build_revision_draft(author:, source_revision: nil) raise ArgumentError, 'Only published or internal Articles accept content revisions' unless revision_proposable? validate_revision_source!(source_revision) if source_revision source_attributes = source_revision&.content_attributes || revision_content_attributes source_article_data = if source_revision&.article_data_captured? source_revision.article_data.deep_dup else revision_article_data end source_article_data[ArticleRevision::CONTENT_LINK_DATA_FIELD.to_s] ||= revision_content_link_data if is_a?(ArticleTechnical) revision = revisions.build( **source_attributes, article_data: source_article_data, author: , review_assignee: default_revision_review_assignee, review_state: 'draft', base_content_digest: revision_content_digest, change_notes: ("Restore revision ##{source_revision.revision_number}" if source_revision) ) page_attributes = source_revision ? source_revision.page_attributes : revision_page_attributes page_attributes.each { |attributes| revision.pages.build(attributes) } revision end |
#clear_state_transition_validation ⇒ Object
Clear state transition validation.
599 600 601 |
# File 'app/models/article.rb', line 599 def clear_state_transition_validation true end |
#content_for_embedding(_content_type = :primary) ⇒ Object
Content for embedding.
1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 |
# File 'app/models/article.rb', line 1386 def (_content_type = :primary) parts = [] parts << "Type: #{article_type_human}" # Core content parts << "Question: #{subject}" if subject.present? parts << "Title: #{title}" if title.present? parts << "Department: #{department}" if department.present? parts << "Objective: #{parse_text_from_html(objective)}" if objective.present? parts << parse_text_from_html(description) if description.present? parts << parse_text_from_html(solution) if solution.present? # Product context - crucial for disambiguation # E.g., "How much does it cost?" applies to specific product lines if product_lines.any? pl_names = product_lines.map(&:lineage_expanded).join(', ') parts << "Applies to Product Lines: #{pl_names}" end if product_categories.any? pc_names = product_categories.map(&:name).join(', ') parts << "Applies to Categories: #{pc_names}" end if items.any? item_info = items.map { |item| "#{item.name} (SKU: #{item.sku})" }.join(', ') parts << "Applies to Items: #{item_info}" end # Tags for additional context parts << "Tags: #{.join(', ')}" if .present? parts.compact.join("\n\n") end |
#create_revision_draft!(author:, source_revision: nil, attributes: {}) ⇒ Object
Create revision draft, raising on failure.
1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 |
# File 'app/models/article.rb', line 1270 def create_revision_draft!(author:, source_revision: nil, attributes: {}) with_lock do if (existing = open_revision) if source_revision existing.errors.add(:base, 'Finish the open revision before restoring another version') raise ActiveRecord::RecordInvalid, existing end return existing end unless revision_proposable? invalid_revision = revisions.build invalid_revision.errors.add(:base, 'Use the normal Article editor until this Article is published or internal') raise ActiveRecord::RecordInvalid, invalid_revision end revision = build_revision_draft(author:, source_revision:) assignable = attributes.slice(*ArticleRevision::EDITABLE_FIELDS, *ArticleRevision::ARTICLE_DATA_FIELDS) if attributes[:pages_attributes].present? revision.pages.clear assignable[:pages_attributes] = attributes[:pages_attributes] end revision.assign_attributes(assignable) revision.save! revision end end |
#effective_published_date ⇒ Object
Effective published date.
765 766 767 |
# File 'app/models/article.rb', line 765 def effective_published_date published_at || created_at end |
#effective_revised_date ⇒ Object
Effective revised date.
770 771 772 |
# File 'app/models/article.rb', line 770 def effective_revised_date revised_at || published_at || created_at end |
#email_url ⇒ Object
Email url.
834 835 836 |
# File 'app/models/article.rb', line 834 def email_url ARTICLE_CRM_BASE_URL + id.to_s end |
#embedded_assets ⇒ ActiveRecord::Relation<EmbeddedAsset>
Returns the associated embedded assets.
205 |
# File 'app/models/article.rb', line 205 has_many :embedded_assets, as: :parent, dependent: :destroy |
#embedding_eligible? ⇒ Boolean
Returns whether the record embedding eligible.
748 749 750 751 752 |
# File 'app/models/article.rb', line 748 def return published? if type == Post.sti_name state.in?(EMBEDDABLE_STATES) && knowledge_corpus? end |
#embedding_type_name ⇒ Object
STI subtypes (ArticleFaq, ArticleTechnical, etc.) must use the base
"Article" type for content_embeddings partitioning and validation.
Post overrides this to return "Post" since it has its own partition entry.
1375 1376 1377 |
# File 'app/models/article.rb', line 1375 def 'Article' end |
#events_for_select ⇒ Object
Events for select.
614 615 616 |
# File 'app/models/article.rb', line 614 def events_for_select [["#{human_state_name.titleize} (Current)", '']] + possible_events_for_select end |
#faq_word_count ⇒ Object
Faq word count.
265 266 267 |
# File 'app/models/article.rb', line 265 def faq_word_count word_count || ActionController::Base.helpers.(solution.to_s).split.size end |
#file_name ⇒ Object
File name.
896 897 898 |
# File 'app/models/article.rb', line 896 def file_name "#{to_param}.pdf" end |
#full_localized_solution(locale = nil, preview_mode: false) ⇒ Object
Full localized solution.
969 970 971 972 973 974 975 976 |
# File 'app/models/article.rb', line 969 def full_localized_solution(locale = nil, preview_mode: false) output = localized_solution(locale, preview_mode: preview_mode).to_s article_pages.order(:position).each do |page| output << "<b>Cause:</b> #{page.headline}" output << "<br><b>Solution:</b> #{page.localized_content}" end output.html_safe end |
#generate_pdf ⇒ Object
Generate pdf.
901 902 903 904 905 906 |
# File 'app/models/article.rb', line 901 def generate_pdf result = Pdf::Document::Article.call(self) path = Rails.application.config.x.temp_storage_path.join(file_name) File.binwrite(path, result.pdf) Upload.uploadify(path, 'article_pdf', nil, file_name) end |
#get_img_src_urls ⇒ Object
Get img src urls.
1079 1080 1081 |
# File 'app/models/article.rb', line 1079 def get_img_src_urls localized_solution_html_doc.xpath('//img').pluck('src') end |
#item_product_lines ⇒ ActiveRecord::Relation<ProductLine>
Returns the associated item product lines.
217 |
# File 'app/models/article.rb', line 217 has_many :item_product_lines, source: :product_lines, through: :items, class_name: 'ProductLine' |
#items ⇒ ActiveRecord::Relation<Item>
Returns the associated items.
214 |
# File 'app/models/article.rb', line 214 has_and_belongs_to_many :items |
#knowledge_corpus? ⇒ Boolean
Returns whether the record knowledge corpus.
743 744 745 |
# File 'app/models/article.rb', line 743 def knowledge_corpus? knowledge_role.in?(KNOWLEDGE_CORPUS_ROLES) end |
#knowledge_role_human ⇒ Object
Knowledge role human.
738 739 740 |
# File 'app/models/article.rb', line 738 def knowledge_role_human KNOWLEDGE_ROLES.fetch(knowledge_role) end |
#latest_revision ⇒ Object
Latest revision.
1159 1160 1161 |
# File 'app/models/article.rb', line 1159 def latest_revision revisions.applied.reverse_chronological.first end |
#link_checks ⇒ ActiveRecord::Relation<LinkCheck>
Returns the associated link checks.
222 |
# File 'app/models/article.rb', line 222 has_and_belongs_to_many :link_checks, uniq: true |
#liquid_variable_context(_locale, _preview_mode) ⇒ Object
Override in subclasses to inject model-specific variables into the Liquid
render context for +localized_solution+.
Returns a Hash that is merged into the base context before rendering.
Keys must be strings (Liquid uses string-keyed hashes).
962 963 964 |
# File 'app/models/article.rb', line 962 def liquid_variable_context(_locale, _preview_mode) {} end |
#localize_price(raw_html) ⇒ Object
Localize price.
930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 |
# File 'app/models/article.rb', line 930 def localize_price(raw_html) solution = raw_html locale ||= I18n.locale regex = /\+\+localprice\((.*?)\)\+\+/ matches = solution.scan(regex).flatten matches.each do |m| ic = CatalogItem.by_skus(m).main_catalogs item_catalog_usa = ic.where(catalog_id: 1).first item_catalog_ca = ic.where(catalog_id: 2).first price = if locale.to_s == 'en-CA' item_catalog_ca.sale_price_in_effect? ? item_catalog_ca.sale_price : item_catalog_ca.bom_price else item_catalog_usa.sale_price_in_effect? ? item_catalog_usa.sale_price : item_catalog_usa.bom_price end solution = solution.gsub("++localprice(#{m})++", ActionController::Base.helpers.number_to_currency(price)) end solution end |
#localized_solution(locale = nil, preview_mode: false, absolute_urls: false) ⇒ String?
Produce the article solution HTML localized for a given locale, with optional product embed processing and optional rewriting of relative URLs to absolute URLs.
Processes Liquid tags, replaces local price markers, and (unless preview_mode) transforms product embed figures into rendered product HTML;
when absolute_urls is true, rewrites href/src attributes on common tags to absolute URLs using WEB_URL and normalizes certain warmlyyours
double-slash/protocol-relative anomalies.
If URL rewriting raises an error, the method returns the localized HTML without absolute URL transformation.
988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 |
# File 'app/models/article.rb', line 988 def localized_solution(locale = nil, preview_mode: false, absolute_urls: false) content = solution return if content.blank? locale ||= I18n.locale body_localized = localize_liquid(content, locale, preview_mode) begin html = localize_price(body_localized) html = (html, locale) unless preview_mode return html unless absolute_urls # Rewrite relative URLs to absolute using WEB_URL. Also normalize mistaken # protocol-relative paths (//floor-heating/...) and double slashes after the # host on absolute warmlyyours.com URLs — URI.join treats //foo as network path. doc = Nokogiri::HTML::DocumentFragment.parse(html) base = WEB_URL %w[a img link script source].each do |tag| attr = case tag when 'a', 'link' then 'href' else 'src' end doc.css(tag).each do |node| val = node[attr] next if val.blank? next if val.start_with?('#') next if val.start_with?('mailto:', 'tel:') node[attr] = normalize_feed_href(val, base: base, locale: locale) end end doc.to_html rescue StandardError body_localized end end |
#localized_solution_html_doc(preview_mode: false) ⇒ Object
Localized solution html doc.
1032 1033 1034 |
# File 'app/models/article.rb', line 1032 def localized_solution_html_doc(preview_mode: false) Nokogiri::HTML(localized_solution(nil, preview_mode: preview_mode)) end |
#normalize_friendly_id(value) ⇒ Object
Normalize friendly id.
785 786 787 |
# File 'app/models/article.rb', line 785 def normalize_friendly_id(value) value.to_s.parameterize(preserve_case: slug_custom.present?) end |
#ok_to_delete? ⇒ Boolean
Placeholder for can can ability
811 812 813 |
# File 'app/models/article.rb', line 811 def ok_to_delete? true end |
#open_revision ⇒ Object
Open revision.
1164 1165 1166 |
# File 'app/models/article.rb', line 1164 def open_revision revisions.open_for_review.reverse_chronological.first end |
#original_author ⇒ Employee?
Returns the original author this record belongs to.
153 |
# File 'app/models/article.rb', line 153 belongs_to :original_author, class_name: 'Employee', optional: true |
#page_title ⇒ Object
Page title.
549 550 551 |
# File 'app/models/article.rb', line 549 def page_title title.presence || subject.presence end |
#possible_events ⇒ Object
Possible events.
583 584 585 |
# File 'app/models/article.rb', line 583 def possible_events state_transitions.map(&:event).sort end |
#possible_events_for_select ⇒ Object
Possible events for select.
609 610 611 |
# File 'app/models/article.rb', line 609 def possible_events_for_select possible_events.map { |evt| [evt.to_s.titleize, evt] } end |
#post_comments ⇒ ActiveRecord::Relation<PostComment>
Returns the associated post comments.
199 |
# File 'app/models/article.rb', line 199 has_many :post_comments, -> { order(:id) }, foreign_key: :post_id, dependent: :destroy |
#precursor ⇒ Article?
Returns the precursor this record belongs to.
155 |
# File 'app/models/article.rb', line 155 belongs_to :precursor, class_name: 'Article', optional: true, inverse_of: :successors |
#prepare_state_transition_validation(_event) ⇒ Object
Prepare state transition validation.
594 595 596 |
# File 'app/models/article.rb', line 594 def prepare_state_transition_validation(_event) true end |
#preview_image ⇒ Image?
Returns the preview image this record belongs to.
161 |
# File 'app/models/article.rb', line 161 belongs_to :preview_image, class_name: 'Image', optional: true |
#primary_site_map ⇒ SiteMap?
Returns the primary site_map for this article (US locale preferred)
659 660 661 662 663 |
# File 'app/models/article.rb', line 659 def primary_site_map return @primary_site_map if defined?(@primary_site_map) @primary_site_map = site_maps.find_by(locale: 'en-US') || site_maps.first end |
#procedure? ⇒ Boolean
Returns whether the record procedure.
816 817 818 |
# File 'app/models/article.rb', line 816 def procedure? is_a?(ArticleProcedure) end |
#process_product_embeds(html, locale) ⇒ String
Process product embeds in HTML to render locale-aware content with working Add to Cart buttons
This ensures products are rendered fresh with current pricing and functional buttons
1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 |
# File 'app/models/article.rb', line 1041 def (html, locale) return html unless html.include?('data-wy-oembed="product"') || html.include?('wy-product-embed') doc = Nokogiri::HTML5.fragment(html) provider = Oembed::ProductProvider.new doc.css('figure.wy-product-embed, figure[data-wy-oembed="product"]').each do |figure| sku = figure['data-product-sku'] next if sku.blank? begin # Render fresh product HTML for this locale fresh_html = provider.render_for_locale(sku, locale) if fresh_html.nil? # Product unavailable - hide it figure['class'] = "#{figure['class']} d-none".strip next end # Replace the figure's inner HTML with fresh content new_doc = Nokogiri::HTML5.fragment(fresh_html) new_content = new_doc.at_css('.blog-product-callout, .blog-product-embed') if new_content # Also extract any JSON-LD schema scripts for SEO schema_scripts = new_doc.css('script[type="application/ld+json"]') figure.inner_html = new_content.to_html + schema_scripts.to_html end rescue StandardError => e Rails.logger.warn "[ProductEmbed] Failed to process product #{sku}: #{e.}" # Keep existing content on error end end doc.to_html end |
#product_categories ⇒ ActiveRecord::Relation<ProductCategory>
Returns the associated product categories.
212 |
# File 'app/models/article.rb', line 212 has_and_belongs_to_many :product_categories |
#product_lines ⇒ ActiveRecord::Relation<ProductLine>
Returns the associated product lines.
210 |
# File 'app/models/article.rb', line 210 has_and_belongs_to_many :product_lines |
#purge_edge_cache(include_indirect_associations: true, extra_urls: []) ⇒ Object
Purge edge cache.
1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 |
# File 'app/models/article.rb', line 1129 def purge_edge_cache(include_indirect_associations: true, extra_urls: []) urls = edge_cache_urls + extra_urls enqueue_edge_cache_purge(urls) if include_indirect_associations product_lines.each do |pl| pl.touch pl.purge_edge_cache end items.each do |i| i.touch i.purge_edge_cache end end urls end |
#reading_time_in_minutes ⇒ Object
Returns reading time in minutes, calculating on the fly if not stored
1628 1629 1630 1631 1632 1633 1634 1635 1636 |
# File 'app/models/article.rb', line 1628 def reading_time_in_minutes return reading_time_minutes if self.class.column_names.include?('reading_time_minutes') && reading_time_minutes.present? # Fallback calculation for records without stored value return 1 if solution.blank? wc = ActionController::Base.helpers.(solution.to_s).split.size (wc / 200.0).ceil.clamp(1, 99) end |
#related_articles_for_display ⇒ Object
Get data for partial views/posts/_related_post
Returns articles in slot order (1→4), skipping nil/unpublished.
564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 |
# File 'app/models/article.rb', line 564 def ids = [ , , , ].compact return Article.none if ids.empty? by_id = Article.published .where(id: ids) .select(:id, :type, :subject, :slug, :preview_image_id, :published_at, :revised_at, :solution, :user_id, :reading_time_minutes) .index_by(&:id) ids.filter_map { |id| by_id[id] } end |
#related_articles_for_select ⇒ Object
Related articles for select.
554 555 556 557 558 559 |
# File 'app/models/article.rb', line 554 def self.class.published .where.not(id: id.to_i) .order(:subject) .pluck("articles.subject || ' (' || TO_CHAR(articles.updated_at, 'YYYY-MM-DD') || ') - ' || articles.id::varchar".sql_safe, :id) end |
#requires_editorial_review? ⇒ Boolean
Returns whether the record requires editorial review.
588 589 590 |
# File 'app/models/article.rb', line 588 def requires_editorial_review? false end |
#revision_article_data ⇒ Object
Revision article data.
1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 |
# File 'app/models/article.rb', line 1183 def revision_article_data data = ArticleRevision::ARTICLE_ATTRIBUTE_FIELDS.index_with { |field| public_send(field) } data.merge!( product_line_ids: product_line_ids.map(&:to_i).sort, product_category_ids: product_category_ids.map(&:to_i).sort, item_ids: item_ids.map(&:to_i).sort, tags: .map(&:to_s).sort, _captured: true ) data[ArticleRevision::CONTENT_LINK_DATA_FIELD] = revision_content_link_data if is_a?(ArticleTechnical) data.stringify_keys end |
#revision_content_attributes ⇒ Object
Revision content attributes.
1169 1170 1171 |
# File 'app/models/article.rb', line 1169 def revision_content_attributes ArticleRevision::CONTENT_FIELDS.index_with { |field| public_send(field) } end |
#revision_content_digest ⇒ Object
Revision content digest.
1174 1175 1176 1177 1178 1179 1180 |
# File 'app/models/article.rb', line 1174 def revision_content_digest content = revision_content_attributes.merge( article_data: revision_article_data, article_pages: revision_page_attributes ) Digest::SHA256.hexdigest(JSON.generate(content)) end |
#revision_content_link_data ⇒ Array<Hash>
Returns ordinary related materials as revision-safe proposal rows.
Consolidation markers remain owned by their dedicated workflow.
1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 |
# File 'app/models/article.rb', line 1200 def revision_content_link_data links = outbound_content_links.excluding_workflow_managed.ordered.with_targets.to_a max_position = links.filter_map(&:position).max next_position = max_position ? max_position + 1 : 0 links.map do |content_link| position = content_link.position if position.nil? position = next_position next_position += 1 end { 'id' => content_link.id, 'key' => ArticleRevision.content_link_key( target_type: content_link.target_type, target_id: content_link.target_id, link_type: content_link.link_type ), 'target_type' => content_link.target_type, 'target_id' => content_link.target_id, 'link_type' => content_link.link_type, 'context' => content_link.context.presence, 'created_by_type' => content_link.created_by_type, 'position' => position } end end |
#revision_page_attributes ⇒ Object
Revision page attributes.
1230 1231 1232 1233 1234 |
# File 'app/models/article.rb', line 1230 def revision_page_attributes article_pages.order(:position).map do |page| page.attributes.slice('headline', 'content', 'position') end end |
#revision_proposable? ⇒ Boolean
Returns whether the record revision proposable.
1154 1155 1156 |
# File 'app/models/article.rb', line 1154 def revision_proposable? uses_revisions? && state.in?(EMBEDDABLE_STATES) end |
#revisions ⇒ ActiveRecord::Relation<ArticleRevision>
=============================================================================
REVISION SYSTEM
Article content revisions are shared by every Article STI subtype. The
Article row remains the live source of truth; draft revisions are proposals
and approved revisions are applied back to that same row.
Architecture (simplified March 2026):
- The articles table is ALWAYS the single source of truth for content.
recordedrevisions are immutable Post snapshots.draft/pending_reviewrevisions are editable review proposals.- Approval updates this row in place and preserves its identity and slug.
Key methods:
- uses_revisions? - Returns true if this article type uses revisions
- snapshot_revision! - Creates an immutable snapshot of current content
- restore_revision! - Copies a past revision's content to the article
- latest_revision - Returns the most recent revision by revision_number
=============================================================================
182 |
# File 'app/models/article.rb', line 182 has_many :revisions, class_name: 'ArticleRevision', dependent: :destroy |
#rma_reason_code ⇒ RmaReasonCode?
Returns the rma reason code this record belongs to.
159 |
# File 'app/models/article.rb', line 159 belongs_to :rma_reason_code, foreign_key: 'problem_code', primary_key: 'code', optional: true |
#sanitize_urls ⇒ Object
Sanitize urls.
909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 |
# File 'app/models/article.rb', line 909 def sanitize_urls return if solution.blank? logger.info "Sanitizing urls for article:#{id}" sd = Seo::DeparameterizeLinks.new ld = Seo::HtmlLinkSanitizer.new imgf = Seo::ImageMissingSizeFiller.new h = solution ld_result = ld.process(h) h = ld_result.html_out h = sd.process(h).html_out h = imgf.process(h).html_out # Nokogiri URL-encodes spaces in href attributes during re-serialization, # turning Liquid variable placeholders like {{ locale }} into {{%20locale%20}}. # Restore them so Liquid can render them correctly at request time. h = restore_liquid_placeholders(h) self.solution = h end |
#schema_dot_org_images ⇒ Object
Schema dot org images.
1084 1085 1086 1087 1088 1089 1090 1091 |
# File 'app/models/article.rb', line 1084 def schema_dot_org_images image_objects = [] image_objects << SchemaDotOrg::ImageObject.new(url: preview_image&.image_url) if preview_image.present? image_objects += get_img_src_urls.map do |url| SchemaDotOrg::ImageObject.new(url: url) end image_objects.compact.uniq end |
#schema_dot_org_structure ⇒ Object
Schema dot org structure.
1094 1095 1096 |
# File 'app/models/article.rb', line 1094 def schema_dot_org_structure {} end |
#schema_dot_org_structure_faq ⇒ Object
Schema dot org structure faq.
1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 |
# File 'app/models/article.rb', line 1099 def schema_dot_org_structure_faq = &.schema_dot_org_structure SchemaDotOrg::Question.new.tap do |s| s.name = subject s.text = subject s.dateCreated = created_at s. = s.answerCount = 1 answer_text = localized_solution.to_s.presence s.acceptedAnswer = SchemaDotOrg::Answer.new(text: answer_text, dateCreated: created_at, author: ) if answer_text end end |
#send_creation_email ⇒ Object
Send creation email.
861 862 863 |
# File 'app/models/article.rb', line 861 def send_creation_email CommunicationBuilder.new(resource: self, template_system_code: 'BULLETIN_CREATE').create end |
#send_update_email ⇒ Object
Send update email.
866 867 868 |
# File 'app/models/article.rb', line 866 def send_update_email CommunicationBuilder.new(resource: self, template_system_code: 'BULLETIN_UPDATE').create end |
#seo_clicks ⇒ Integer?
GSC clicks (28 days) - verified Google search traffic
673 674 675 |
# File 'app/models/article.rb', line 673 def seo_clicks primary_site_map&.seo_clicks end |
#seo_keywords_count ⇒ Integer?
Keywords count from Ahrefs
697 698 699 |
# File 'app/models/article.rb', line 697 def seo_keywords_count primary_site_map&.seo_keywords_count end |
#seo_last_synced_at ⇒ DateTime?
Last sync timestamp for SEO metrics
715 716 717 |
# File 'app/models/article.rb', line 715 def seo_last_synced_at primary_site_map&.seo_synced_at end |
#seo_metrics_synced? ⇒ Boolean
Check if SEO metrics are available
709 710 711 |
# File 'app/models/article.rb', line 709 def seo_metrics_synced? primary_site_map&.seo_synced_at.present? end |
#seo_top_keyword ⇒ String?
Top keyword from Ahrefs
685 686 687 |
# File 'app/models/article.rb', line 685 def seo_top_keyword primary_site_map&.seo_top_keyword end |
#seo_top_position ⇒ Integer?
Top position from Ahrefs
691 692 693 |
# File 'app/models/article.rb', line 691 def seo_top_position primary_site_map&.seo_top_position end |
#seo_traffic ⇒ Integer?
Ahrefs estimated organic traffic
679 680 681 |
# File 'app/models/article.rb', line 679 def seo_traffic primary_site_map&.seo_traffic end |
#seo_traffic_value ⇒ Integer?
Traffic value from Ahrefs
703 704 705 |
# File 'app/models/article.rb', line 703 def seo_traffic_value primary_site_map&.seo_traffic_value end |
#should_generate_new_friendly_id? ⇒ Boolean
Returns whether the record should generate new friendly id.
790 791 792 793 794 795 |
# File 'app/models/article.rb', line 790 def should_generate_new_friendly_id? return false if preserve_revision_slug && slug.present? live_article = state.in?(EMBEDDABLE_STATES) slug.nil? || slug_custom_changed? || (!live_article && subject_changed?) || super end |
#site_maps ⇒ ActiveRecord::Relation<SiteMap>
Returns the associated site maps.
201 |
# File 'app/models/article.rb', line 201 has_many :site_maps, as: :resource, dependent: :destroy |
#slug_candidates ⇒ Object
Slug candidates.
775 776 777 778 779 780 781 |
# File 'app/models/article.rb', line 775 def slug_candidates [ [:slug_custom], [:subject], %i[article_type_human subject] ] end |
#snapshot_revision!(change_notes: nil, author_id: nil) ⇒ Object
Creates an immutable snapshot of the article's current content fields.
Called by PostRevisionSnapshotHandler when PostContentUpdated fires.
1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 |
# File 'app/models/article.rb', line 1303 def snapshot_revision!(change_notes: nil, author_id: nil) return unless uses_revisions? with_lock do = || updater_id || creator_id revision = revisions.create!( author: Employee.find_by(id: ), change_notes: change_notes, review_state: 'recorded', article_data: revision_article_data, **revision_content_attributes ) revision_page_attributes.each { |attributes| revision.pages.create!(attributes) } revision end end |
#solution_text ⇒ Object
Solution text.
1368 1369 1370 |
# File 'app/models/article.rb', line 1368 def solution_text parse_text_from_html(solution) end |
#subject_with_type ⇒ Object
Subject with type.
826 827 828 |
# File 'app/models/article.rb', line 826 def subject_with_type "#{subject} (#{article_type_human})" end |
#successor_article ⇒ Article?
Returns the successor article this record belongs to.
157 |
# File 'app/models/article.rb', line 157 belongs_to :successor_article, class_name: 'Article', optional: true, inverse_of: :precursor |
#successors ⇒ ActiveRecord::Relation<Article>
Returns the associated successors.
195 |
# File 'app/models/article.rb', line 195 has_many :successors, class_name: 'Article', foreign_key: :precursor_id, inverse_of: :precursor |
#to_json_ld ⇒ Object
To json ld.
1119 1120 1121 1122 1123 1124 |
# File 'app/models/article.rb', line 1119 def to_json_ld schema_dot_org_structure.to_s rescue StandardError => e ErrorReporting.error e logger.error e end |
#to_json_raw ⇒ Object
To json raw.
1114 1115 1116 |
# File 'app/models/article.rb', line 1114 def to_json_raw schema_dot_org_structure.to_json end |
#topics ⇒ ActiveRecord::Relation<Topic>
Returns the associated topics.
208 |
# File 'app/models/article.rb', line 208 has_and_belongs_to_many :topics |
#uploads ⇒ ActiveRecord::Relation<Upload>
Returns the associated uploads.
220 |
# File 'app/models/article.rb', line 220 has_and_belongs_to_many :uploads, inverse_of: :articles |
#user ⇒ Employee
The current author
151 |
# File 'app/models/article.rb', line 151 belongs_to :user, class_name: 'Employee', optional: true |
#uses_revisions? ⇒ Boolean
1149 1150 1151 |
# File 'app/models/article.rb', line 1149 def uses_revisions? type.in?(STI_TYPES.keys) end |
#visit_count_30d ⇒ Integer?
First-party visit count (30 days) - most reliable traffic metric
667 668 669 |
# File 'app/models/article.rb', line 667 def visit_count_30d primary_site_map&.visit_count_30d end |
#votes ⇒ ActiveRecord::Relation<Vote>
Returns the associated votes.
203 |
# File 'app/models/article.rb', line 203 has_many :votes, as: :resource, dependent: :destroy |