Class: Article
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- Article
- Extended by:
- FriendlyId
- Includes:
- Memery, Models::Auditable, Models::CrossLinkable, 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
Defined Under Namespace
Classes: Archiver
Constant Summary collapse
- FAQ_MAX_WORDS =
150- FAQ_TARGET_WORDS =
(40..60).freeze
- 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::DEFAULT_MODEL, Models::Embeddable::MAX_CONTENT_LENGTH
Constants included from Models::Auditable
Models::Auditable::ALWAYS_IGNORED
Instance Attribute Summary collapse
- #schema_markup ⇒ Object readonly
- #serial_number_high_range ⇒ Object readonly
- #serial_number_low_range ⇒ Object readonly
- #subject ⇒ Object readonly
Belongs to collapse
- #active_revision ⇒ ArticleRevision
- #original_author ⇒ Employee
- #precursor ⇒ Article
- #preview_image ⇒ Image
- #rma_reason_code ⇒ RmaReasonCode
- #successor_article ⇒ Article
-
#user ⇒ Employee
The current author.
Methods included from Models::Auditable
Has many collapse
- #article_pages ⇒ ActiveRecord::Relation<ArticlePage>
- #embedded_assets ⇒ ActiveRecord::Relation<EmbeddedAsset>
- #item_product_lines ⇒ ActiveRecord::Relation<ProductLine>
- #post_comments ⇒ ActiveRecord::Relation<PostComment>
-
#revisions ⇒ ActiveRecord::Relation<ArticleRevision>
============================================================================= REVISION SYSTEM ============================================================================= The revision system maintains immutable, append-only snapshots of article content for version history.
- #site_maps ⇒ ActiveRecord::Relation<SiteMap>
- #successors ⇒ ActiveRecord::Relation<Article>
- #votes ⇒ ActiveRecord::Relation<Vote>
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>
- #link_checks ⇒ ActiveRecord::Relation<LinkCheck>
- #product_categories ⇒ ActiveRecord::Relation<ProductCategory>
- #product_lines ⇒ ActiveRecord::Relation<ProductLine>
- #topics ⇒ ActiveRecord::Relation<Topic>
- #uploads ⇒ ActiveRecord::Relation<Upload>
Class Method Summary collapse
- .all_product_lines ⇒ Object
-
.archived ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are archived.
- .available_schema_types ⇒ Object
-
.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.
-
.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.
-
.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 ⇒ Object
-
.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.
-
.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
- .publish_posts_due_for_publishing(logger = Rails.logger) ⇒ Object
-
.published ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are published.
- .ransackable_scopes(_auth_object = nil) ⇒ Object
-
.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
-
.sorted_by_most_recent_first ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are sorted by most recent first.
- .sorted_with_booster ⇒ Object
- .states_for_select ⇒ Object
- .types_options ⇒ Object
- .types_options_for_search ⇒ Object
-
.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
- #applicable_product_category_urls ⇒ Object
-
#applicable_product_line_urls ⇒ Object
============================================================ End Revision System Methods ============================================================.
- #article_type_human ⇒ Object
- #author ⇒ Object
- #breadcrumbs_hash ⇒ Object
- #content_for_embedding(_content_type = :primary) ⇒ Object
- #effective_published_date ⇒ Object
- #effective_revised_date ⇒ Object
- #email_url ⇒ Object
-
#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
- #faq_word_count ⇒ Object
- #file_name ⇒ Object
- #full_localized_solution(locale = nil, preview_mode: false) ⇒ Object
- #generate_pdf ⇒ Object
- #get_img_src_urls ⇒ Object
- #latest_revision ⇒ Object
-
#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
-
#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
- #normalize_friendly_id(value) ⇒ Object
-
#ok_to_delete? ⇒ Boolean
Placeholder for can can ability.
- #page_title ⇒ Object
- #possible_events ⇒ Object
- #possible_events_for_select ⇒ Object
-
#primary_site_map ⇒ SiteMap?
Returns the primary site_map for this article (US locale preferred).
- #procedure? ⇒ Boolean
-
#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
-
#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
-
#restore_revision!(revision, author:) ⇒ Object
Copies content from a past revision back to the article, then saves normally.
- #sanitize_urls ⇒ Object
- #schema_dot_org_images ⇒ Object
- #schema_dot_org_structure ⇒ Object
- #schema_dot_org_structure_faq ⇒ Object
- #send_creation_email ⇒ Object
- #send_update_email ⇒ Object
-
#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
- #slug_candidates ⇒ Object
-
#snapshot_revision!(change_notes: nil, author_id: nil) ⇒ Object
Creates an immutable snapshot of the article's current content fields.
- #solution_text ⇒ Object
- #subject_with_type ⇒ Object
- #to_json_ld ⇒ Object
- #to_json_raw ⇒ Object
-
#uses_revisions? ⇒ Boolean
============================================================ Revision System Methods ============================================================.
-
#visit_count_30d ⇒ Integer?
First-party visit count (30 days) - most reliable traffic metric.
Methods included from Models::CrossLinkable
#content_links_count, #linked_content, #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_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
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::EventPublishable
Instance Attribute Details
#schema_markup ⇒ Object (readonly)
304 |
# File 'app/models/article.rb', line 304 validates :schema_markup, presence: false |
#serial_number_high_range ⇒ Object (readonly)
302 |
# File 'app/models/article.rb', line 302 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)
301 |
# File 'app/models/article.rb', line 301 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)
303 |
# File 'app/models/article.rb', line 303 validates :subject, presence: true |
Class Method Details
.all_product_lines ⇒ Object
639 |
# File 'app/models/article.rb', line 639 def self.all_product_lines; end |
.archived ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are archived. Active Record Scope
338 |
# File 'app/models/article.rb', line 338 scope :archived, -> { where(state: 'archived') } |
.available_schema_types ⇒ Object
649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 |
# File 'app/models/article.rb', line 649 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 |
.drafts ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are drafts. Active Record Scope
334 |
# File 'app/models/article.rb', line 334 scope :drafts, -> { where(state: 'draft') } |
.due_for_publishing ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are due for publishing. Active Record Scope
339 |
# File 'app/models/article.rb', line 339 scope :due_for_publishing, -> { waiting_to_publish.where(Article[:publish_on].lteq(Time.current)) } |
.embeddable_content_types ⇒ Object
Embeddable configuration
1014 1015 1016 |
# File 'app/models/article.rb', line 1014 def self. [:primary] end |
.embedded_in_posts ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are embedded in posts. Active Record Scope
219 220 221 222 223 224 225 226 227 |
# File 'app/models/article.rb', line 219 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 } |
.faqs ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are faqs. Active Record Scope
165 |
# File 'app/models/article.rb', line 165 scope :faqs, -> { where(type: 'ArticleFaq') } |
.for_item_sku ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are for item sku. Active Record Scope
181 |
# File 'app/models/article.rb', line 181 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
176 |
# File 'app/models/article.rb', line 176 scope :for_product_category_url_without_order, ->(pc_url) { where.overlap(applies_to_product_category_ids: ProductCategory.where(url: pc_url).pluck(:id).compact) } |
.for_product_line_url ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are for product line url. Active Record Scope
177 |
# File 'app/models/article.rb', line 177 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
345 346 347 348 349 |
# File 'app/models/article.rb', line 345 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).pluck(:id) 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
340 341 342 343 344 |
# File 'app/models/article.rb', line 340 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).pluck(:id) 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
168 169 170 171 172 173 174 175 |
# File 'app/models/article.rb', line 168 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).pluck(:id).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
178 |
# File 'app/models/article.rb', line 178 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
179 |
# File 'app/models/article.rb', line 179 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
180 |
# File 'app/models/article.rb', line 180 scope :for_support_portal, -> { where(support: true) } |
.has_comments ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are has comments. Active Record Scope
253 254 255 256 257 258 259 260 261 262 |
# File 'app/models/article.rb', line 253 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
241 242 243 244 245 246 247 248 249 250 |
# File 'app/models/article.rb', line 241 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
274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 |
# File 'app/models/article.rb', line 274 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 { 'solution ILIKE ?' }.join(' OR ') case value when 'true' where(clause, *patterns) when 'false' where.not(clause, *patterns) else all end } |
.most_recent_first ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are most recent first. Active Record Scope
185 |
# File 'app/models/article.rb', line 185 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
238 |
# File 'app/models/article.rb', line 238 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
337 |
# File 'app/models/article.rb', line 337 scope :not_for_public, -> { where(state: 'not_for_public') } |
.options_for_select ⇒ Object
673 674 675 |
# File 'app/models/article.rb', line 673 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
210 211 212 213 214 215 216 217 218 |
# File 'app/models/article.rb', line 210 scope :orphan_faqs, ->(value = 'true') { return all unless value == 'true' faqs .where(sales: false, support: false) .where_not_exists(: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
203 204 205 206 207 208 209 |
# File 'app/models/article.rb', line 203 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 } |
.press_industry_reports ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are press industry reports. Active Record Scope
184 |
# File 'app/models/article.rb', line 184 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
183 |
# File 'app/models/article.rb', line 183 scope :press_releases, -> { tagged_with('press-release') } |
.procedure_types ⇒ Object
645 646 647 |
# File 'app/models/article.rb', line 645 def self.procedure_types ArticleProcedure::DEPARTMENTS.map { |d| [d, d] }.sort end |
.publish_posts_due_for_publishing(logger = Rails.logger) ⇒ Object
580 581 582 583 584 585 586 |
# File 'app/models/article.rb', line 580 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
336 |
# File 'app/models/article.rb', line 336 scope :published, -> { where(state: 'published') } |
.ransackable_scopes(_auth_object = nil) ⇒ Object
486 487 488 489 |
# File 'app/models/article.rb', line 486 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)
1198 1199 1200 |
# File 'app/models/article.rb', line 1198 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
230 231 232 233 234 235 |
# File 'app/models/article.rb', line 230 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
616 617 618 619 620 |
# File 'app/models/article.rb', line 616 def self.slug_find(slug) return unless slug.present? 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
166 |
# File 'app/models/article.rb', line 166 scope :sorted_by_most_recent_first, -> { order(Article[:created_at].desc) } |
.sorted_with_booster ⇒ Object
409 410 411 412 413 414 415 416 417 418 419 420 421 |
# File 'app/models/article.rb', line 409 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
576 577 578 |
# File 'app/models/article.rb', line 576 def self.states_for_select state_machines[:state].states.map { |s| [s.human_name.titleize, s.value] } end |
.types_options ⇒ Object
687 688 689 |
# File 'app/models/article.rb', line 687 def self. STI_TYPES.map { |type, label| [label, type] }.sort end |
.types_options_for_search ⇒ Object
691 692 693 |
# File 'app/models/article.rb', line 691 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
335 |
# File 'app/models/article.rb', line 335 scope :waiting_to_publish, -> { where(state: 'scheduled') } |
.with_votes ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are with votes. Active Record Scope
186 187 188 |
# File 'app/models/article.rb', line 186 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
#active_revision ⇒ ArticleRevision
138 |
# File 'app/models/article.rb', line 138 belongs_to :active_revision, class_name: 'ArticleRevision', optional: true |
#applicable_product_category_urls ⇒ Object
998 999 1000 |
# File 'app/models/article.rb', line 998 def applicable_product_category_urls ProductCategory.where(id: applies_to_product_category_ids).order(:url).pluck(:url) end |
#applicable_product_line_urls ⇒ Object
============================================================
End Revision System Methods
994 995 996 |
# File 'app/models/article.rb', line 994 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>
144 |
# File 'app/models/article.rb', line 144 has_many :article_pages, dependent: :destroy |
#article_type_human ⇒ Object
631 632 633 |
# File 'app/models/article.rb', line 631 def article_type_human self.class.name.delete_prefix('Article').presence || 'Article' end |
#author ⇒ Object
612 613 614 |
# File 'app/models/article.rb', line 612 def || creator || updater end |
#breadcrumbs_hash ⇒ Object
423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 |
# File 'app/models/article.rb', line 423 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 } rescue StandardException Rails.logger.error 'Error in breadcrumb path formatting' end bc end |
#content_for_embedding(_content_type = :primary) ⇒ Object
1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 |
# File 'app/models/article.rb', line 1018 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 << 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 |
#effective_published_date ⇒ Object
588 589 590 |
# File 'app/models/article.rb', line 588 def effective_published_date published_at || created_at end |
#effective_revised_date ⇒ Object
592 593 594 |
# File 'app/models/article.rb', line 592 def effective_revised_date revised_at || published_at || created_at end |
#email_url ⇒ Object
641 642 643 |
# File 'app/models/article.rb', line 641 def email_url ARTICLE_CRM_BASE_URL + id.to_s end |
#embedded_assets ⇒ ActiveRecord::Relation<EmbeddedAsset>
148 |
# File 'app/models/article.rb', line 148 has_many :embedded_assets, as: :parent, dependent: :destroy |
#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.
1009 1010 1011 |
# File 'app/models/article.rb', line 1009 def 'Article' end |
#events_for_select ⇒ Object
482 483 484 |
# File 'app/models/article.rb', line 482 def events_for_select [["#{human_state_name.titleize} (Current)", '']] + possible_events_for_select end |
#faq_word_count ⇒ Object
197 198 199 |
# File 'app/models/article.rb', line 197 def faq_word_count word_count || ActionController::Base.helpers.(solution.to_s).split.size end |
#file_name ⇒ Object
695 696 697 |
# File 'app/models/article.rb', line 695 def file_name "#{to_param}.pdf" end |
#full_localized_solution(locale = nil, preview_mode: false) ⇒ Object
759 760 761 762 763 764 765 766 |
# File 'app/models/article.rb', line 759 def full_localized_solution(locale = nil, preview_mode: false) output = "#{localized_solution(locale, preview_mode: preview_mode)}" 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
699 700 701 702 703 704 |
# File 'app/models/article.rb', line 699 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
866 867 868 |
# File 'app/models/article.rb', line 866 def get_img_src_urls localized_solution_html_doc.xpath('//img').map { |img| img['src'] } end |
#item_product_lines ⇒ ActiveRecord::Relation<ProductLine>
155 |
# File 'app/models/article.rb', line 155 has_many :item_product_lines, source: :product_lines, through: :items, class_name: 'ProductLine' |
#items ⇒ ActiveRecord::Relation<Item>
153 |
# File 'app/models/article.rb', line 153 has_and_belongs_to_many :items |
#latest_revision ⇒ Object
932 933 934 |
# File 'app/models/article.rb', line 932 def latest_revision revisions.reverse_chronological.first end |
#link_checks ⇒ ActiveRecord::Relation<LinkCheck>
158 |
# File 'app/models/article.rb', line 158 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).
755 756 757 |
# File 'app/models/article.rb', line 755 def liquid_variable_context(_locale, _preview_mode) {} end |
#localize_price(raw_html) ⇒ Object
725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 |
# File 'app/models/article.rb', line 725 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.
778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 |
# File 'app/models/article.rb', line 778 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
820 821 822 |
# File 'app/models/article.rb', line 820 def localized_solution_html_doc(preview_mode: false) Nokogiri::HTML(localized_solution(nil, preview_mode: preview_mode)) end |
#normalize_friendly_id(value) ⇒ Object
604 605 606 |
# File 'app/models/article.rb', line 604 def normalize_friendly_id(value) value.to_s.parameterize(preserve_case: slug_custom.present?) end |
#ok_to_delete? ⇒ Boolean
Placeholder for can can ability
623 624 625 |
# File 'app/models/article.rb', line 623 def ok_to_delete? true end |
#original_author ⇒ Employee
112 |
# File 'app/models/article.rb', line 112 belongs_to :original_author, class_name: 'Employee', optional: true |
#page_title ⇒ Object
442 443 444 |
# File 'app/models/article.rb', line 442 def page_title title.presence || subject.presence end |
#possible_events ⇒ Object
474 475 476 |
# File 'app/models/article.rb', line 474 def possible_events state_transitions.map(&:event).sort end |
#possible_events_for_select ⇒ Object
478 479 480 |
# File 'app/models/article.rb', line 478 def possible_events_for_select possible_events.map { |evt| [evt.to_s.titleize, evt] } end |
#post_comments ⇒ ActiveRecord::Relation<PostComment>
145 |
# File 'app/models/article.rb', line 145 has_many :post_comments, -> { order(:id) }, foreign_key: :post_id, dependent: :destroy |
#precursor ⇒ Article
113 |
# File 'app/models/article.rb', line 113 belongs_to :precursor, class_name: 'Article', optional: true, inverse_of: :successors |
#preview_image ⇒ Image
116 |
# File 'app/models/article.rb', line 116 belongs_to :preview_image, class_name: 'Image', optional: true |
#primary_site_map ⇒ SiteMap?
Returns the primary site_map for this article (US locale preferred)
516 517 518 519 520 |
# File 'app/models/article.rb', line 516 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
627 628 629 |
# File 'app/models/article.rb', line 627 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
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 863 864 |
# File 'app/models/article.rb', line 829 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>
152 |
# File 'app/models/article.rb', line 152 has_and_belongs_to_many :product_categories |
#product_lines ⇒ ActiveRecord::Relation<ProductLine>
151 |
# File 'app/models/article.rb', line 151 has_and_belongs_to_many :product_lines |
#purge_edge_cache(include_indirect_associations: true, extra_urls: []) ⇒ Object
908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 |
# File 'app/models/article.rb', line 908 def purge_edge_cache(include_indirect_associations: true, extra_urls: []) urls = site_maps.map(&:url) + extra_urls EdgeCacheWorker.perform_async('urls' => urls) if urls.present? 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
1187 1188 1189 1190 1191 1192 1193 1194 1195 |
# File 'app/models/article.rb', line 1187 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.
456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 |
# File 'app/models/article.rb', line 456 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
446 447 448 449 450 451 |
# File 'app/models/article.rb', line 446 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 |
#restore_revision!(revision, author:) ⇒ Object
Copies content from a past revision back to the article, then saves normally.
The after_commit event (PostContentUpdated) triggers snapshot creation via
PostRevisionSnapshotHandler.
939 940 941 942 943 944 945 |
# File 'app/models/article.rb', line 939 def restore_revision!(revision, author:) self.revision_change_notes = "Restored from revision ##{revision.revision_number}" self.auto_update_revised_at = true if respond_to?(:auto_update_revised_at=) attrs = revision.content_attributes attrs[:updater_id] = &.id update!(attrs) end |
#revisions ⇒ ActiveRecord::Relation<ArticleRevision>
=============================================================================
REVISION SYSTEM
The revision system maintains immutable, append-only snapshots of article
content for version history. Currently only Posts use this system.
Architecture (simplified March 2026):
- The articles table is ALWAYS the single source of truth for content.
- ArticleRevision rows are immutable snapshots created after content saves.
- No service ever needs to update a revision row — revisions are write-once.
- Restoring a past revision copies its fields back to the article, which
triggers a normal save and a new snapshot.
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
=============================================================================
137 |
# File 'app/models/article.rb', line 137 has_many :revisions, class_name: 'ArticleRevision', dependent: :destroy |
#rma_reason_code ⇒ RmaReasonCode
115 |
# File 'app/models/article.rb', line 115 belongs_to :rma_reason_code, foreign_key: 'problem_code', primary_key: 'code', optional: true |
#sanitize_urls ⇒ Object
706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 |
# File 'app/models/article.rb', line 706 def sanitize_urls return unless solution.present? 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
870 871 872 873 874 875 876 877 |
# File 'app/models/article.rb', line 870 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
879 880 881 |
# File 'app/models/article.rb', line 879 def schema_dot_org_structure {} end |
#schema_dot_org_structure_faq ⇒ Object
883 884 885 886 887 888 889 890 891 892 893 894 895 |
# File 'app/models/article.rb', line 883 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
665 666 667 |
# File 'app/models/article.rb', line 665 def send_creation_email CommunicationBuilder.new(resource: self, template_system_code: 'BULLETIN_CREATE').create end |
#send_update_email ⇒ Object
669 670 671 |
# File 'app/models/article.rb', line 669 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
530 531 532 |
# File 'app/models/article.rb', line 530 def seo_clicks primary_site_map&.seo_clicks end |
#seo_keywords_count ⇒ Integer?
Keywords count from Ahrefs
554 555 556 |
# File 'app/models/article.rb', line 554 def seo_keywords_count primary_site_map&.seo_keywords_count end |
#seo_last_synced_at ⇒ DateTime?
Last sync timestamp for SEO metrics
572 573 574 |
# File 'app/models/article.rb', line 572 def seo_last_synced_at primary_site_map&.seo_synced_at end |
#seo_metrics_synced? ⇒ Boolean
Check if SEO metrics are available
566 567 568 |
# File 'app/models/article.rb', line 566 def seo_metrics_synced? primary_site_map&.seo_synced_at.present? end |
#seo_top_keyword ⇒ String?
Top keyword from Ahrefs
542 543 544 |
# File 'app/models/article.rb', line 542 def seo_top_keyword primary_site_map&.seo_top_keyword end |
#seo_top_position ⇒ Integer?
Top position from Ahrefs
548 549 550 |
# File 'app/models/article.rb', line 548 def seo_top_position primary_site_map&.seo_top_position end |
#seo_traffic ⇒ Integer?
Ahrefs estimated organic traffic
536 537 538 |
# File 'app/models/article.rb', line 536 def seo_traffic primary_site_map&.seo_traffic end |
#seo_traffic_value ⇒ Integer?
Traffic value from Ahrefs
560 561 562 |
# File 'app/models/article.rb', line 560 def seo_traffic_value primary_site_map&.seo_traffic_value end |
#should_generate_new_friendly_id? ⇒ Boolean
608 609 610 |
# File 'app/models/article.rb', line 608 def should_generate_new_friendly_id? slug.nil? || slug_custom_changed? || (!published? && subject_changed?) || super end |
#site_maps ⇒ ActiveRecord::Relation<SiteMap>
146 |
# File 'app/models/article.rb', line 146 has_many :site_maps, as: :resource, dependent: :destroy |
#slug_candidates ⇒ Object
596 597 598 599 600 601 602 |
# File 'app/models/article.rb', line 596 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.
949 950 951 952 953 954 955 956 957 958 |
# File 'app/models/article.rb', line 949 def snapshot_revision!(change_notes: nil, author_id: nil) return unless uses_revisions? = || updater_id || creator_id revisions.create!( author: Employee.find_by(id: ), change_notes: change_notes, **ArticleRevision::CONTENT_FIELDS.index_with { |f| send(f) } ) end |
#solution_text ⇒ Object
1002 1003 1004 |
# File 'app/models/article.rb', line 1002 def solution_text parse_text_from_html(solution) end |
#subject_with_type ⇒ Object
635 636 637 |
# File 'app/models/article.rb', line 635 def subject_with_type "#{subject} (#{article_type_human})" end |
#successor_article ⇒ Article
114 |
# File 'app/models/article.rb', line 114 belongs_to :successor_article, class_name: 'Article', optional: true, inverse_of: :precursor |
#successors ⇒ ActiveRecord::Relation<Article>
143 |
# File 'app/models/article.rb', line 143 has_many :successors, class_name: 'Article', foreign_key: :precursor_id, inverse_of: :precursor |
#to_json_ld ⇒ Object
901 902 903 904 905 906 |
# File 'app/models/article.rb', line 901 def to_json_ld schema_dot_org_structure.to_s rescue StandardError => e ErrorReporting.error e logger.error e end |
#to_json_raw ⇒ Object
897 898 899 |
# File 'app/models/article.rb', line 897 def to_json_raw schema_dot_org_structure.to_json end |
#topics ⇒ ActiveRecord::Relation<Topic>
150 |
# File 'app/models/article.rb', line 150 has_and_belongs_to_many :topics |
#uploads ⇒ ActiveRecord::Relation<Upload>
157 |
# File 'app/models/article.rb', line 157 has_and_belongs_to_many :uploads, inverse_of: :articles |
#user ⇒ Employee
The current author
111 |
# File 'app/models/article.rb', line 111 belongs_to :user, class_name: 'Employee', optional: true |
#uses_revisions? ⇒ Boolean
============================================================
Revision System Methods
928 929 930 |
# File 'app/models/article.rb', line 928 def uses_revisions? is_a?(Post) end |
#visit_count_30d ⇒ Integer?
First-party visit count (30 days) - most reliable traffic metric
524 525 526 |
# File 'app/models/article.rb', line 524 def visit_count_30d primary_site_map&.visit_count_30d end |
#votes ⇒ ActiveRecord::Relation<Vote>
147 |
# File 'app/models/article.rb', line 147 has_many :votes, as: :resource, dependent: :destroy |