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 =
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 Schedulable
Schedulable::SIMPLE_FORM_OPTIONS
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 Schedulable
Methods included from Models::AfterCommittable
Methods included from Models::EventPublishable
Instance Attribute Details
#schema_markup ⇒ Object (readonly)
317 |
# File 'app/models/article.rb', line 317 validates :schema_markup, presence: false |
#serial_number_high_range ⇒ Object (readonly)
315 |
# File 'app/models/article.rb', line 315 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)
314 |
# File 'app/models/article.rb', line 314 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)
316 |
# File 'app/models/article.rb', line 316 validates :subject, presence: true |
Class Method Details
.all_product_lines ⇒ Object
649 |
# File 'app/models/article.rb', line 649 def self.all_product_lines; end |
.archived ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are archived. Active Record Scope
339 |
# File 'app/models/article.rb', line 339 scope :archived, -> { where(state: 'archived') } |
.available_schema_types ⇒ Object
659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 |
# File 'app/models/article.rb', line 659 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
335 |
# File 'app/models/article.rb', line 335 scope :drafts, -> { where(state: 'draft') } |
.due_for_publishing ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are due for publishing. Active Record Scope
340 |
# File 'app/models/article.rb', line 340 scope :due_for_publishing, -> { waiting_to_publish.where(Article[:publish_on].lteq(Time.current)) } |
.embeddable_content_types ⇒ Object
Embeddable configuration
1024 1025 1026 |
# File 'app/models/article.rb', line 1024 def self. [:primary] end |
.embedded_in_posts ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are embedded in posts. Active Record Scope
232 233 234 235 236 237 238 239 240 |
# File 'app/models/article.rb', line 232 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
175 |
# File 'app/models/article.rb', line 175 scope :faqs, -> { where(type: 'ArticleFaq') } |
.for_item_sku ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are for item sku. Active Record Scope
191 |
# File 'app/models/article.rb', line 191 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
186 |
# File 'app/models/article.rb', line 186 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
187 |
# File 'app/models/article.rb', line 187 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
346 347 348 349 350 |
# File 'app/models/article.rb', line 346 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
341 342 343 344 345 |
# File 'app/models/article.rb', line 341 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
178 179 180 181 182 183 184 185 |
# File 'app/models/article.rb', line 178 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
188 |
# File 'app/models/article.rb', line 188 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
189 |
# File 'app/models/article.rb', line 189 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
190 |
# File 'app/models/article.rb', line 190 scope :for_support_portal, -> { where(support: true) } |
.has_comments ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are has comments. Active Record Scope
266 267 268 269 270 271 272 273 274 275 |
# File 'app/models/article.rb', line 266 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
254 255 256 257 258 259 260 261 262 263 |
# File 'app/models/article.rb', line 254 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
287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 |
# File 'app/models/article.rb', line 287 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 } |
.most_recent_first ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are most recent first. Active Record Scope
195 |
# File 'app/models/article.rb', line 195 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
251 |
# File 'app/models/article.rb', line 251 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
338 |
# File 'app/models/article.rb', line 338 scope :not_for_public, -> { where(state: 'not_for_public') } |
.options_for_select ⇒ Object
683 684 685 |
# File 'app/models/article.rb', line 683 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
223 224 225 226 227 228 229 230 231 |
# File 'app/models/article.rb', line 223 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
216 217 218 219 220 221 222 |
# File 'app/models/article.rb', line 216 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
194 |
# File 'app/models/article.rb', line 194 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
193 |
# File 'app/models/article.rb', line 193 scope :press_releases, -> { tagged_with('press-release') } |
.procedure_types ⇒ Object
655 656 657 |
# File 'app/models/article.rb', line 655 def self.procedure_types ArticleProcedure::DEPARTMENTS.map { |d| [d, d] }.sort end |
.publish_posts_due_for_publishing(logger = Rails.logger) ⇒ Object
590 591 592 593 594 595 596 |
# File 'app/models/article.rb', line 590 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
337 |
# File 'app/models/article.rb', line 337 scope :published, -> { where(state: 'published') } |
.ransackable_scopes(_auth_object = nil) ⇒ Object
487 488 489 490 |
# File 'app/models/article.rb', line 487 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)
1208 1209 1210 |
# File 'app/models/article.rb', line 1208 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
243 244 245 246 247 248 |
# File 'app/models/article.rb', line 243 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
626 627 628 629 630 |
# File 'app/models/article.rb', line 626 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
176 |
# File 'app/models/article.rb', line 176 scope :sorted_by_most_recent_first, -> { order(Article[:created_at].desc) } |
.sorted_with_booster ⇒ Object
410 411 412 413 414 415 416 417 418 419 420 421 422 |
# File 'app/models/article.rb', line 410 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
586 587 588 |
# File 'app/models/article.rb', line 586 def self.states_for_select state_machines[:state].states.map { |s| [s.human_name.titleize, s.value] } end |
.types_options ⇒ Object
697 698 699 |
# File 'app/models/article.rb', line 697 def self. STI_TYPES.map { |type, label| [label, type] }.sort end |
.types_options_for_search ⇒ Object
701 702 703 |
# File 'app/models/article.rb', line 701 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
336 |
# File 'app/models/article.rb', line 336 scope :waiting_to_publish, -> { where(state: 'scheduled') } |
.with_votes ⇒ ActiveRecord::Relation<Article>
A relation of Articles that are with votes. Active Record Scope
196 197 198 |
# File 'app/models/article.rb', line 196 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
139 |
# File 'app/models/article.rb', line 139 belongs_to :active_revision, class_name: 'ArticleRevision', optional: true |
#applicable_product_category_urls ⇒ Object
1008 1009 1010 |
# File 'app/models/article.rb', line 1008 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
1004 1005 1006 |
# File 'app/models/article.rb', line 1004 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>
154 |
# File 'app/models/article.rb', line 154 has_many :article_pages, dependent: :destroy |
#article_type_human ⇒ Object
641 642 643 |
# File 'app/models/article.rb', line 641 def article_type_human self.class.name.delete_prefix('Article').presence || 'Article' end |
#author ⇒ Object
622 623 624 |
# File 'app/models/article.rb', line 622 def || creator || updater end |
#breadcrumbs_hash ⇒ Object
424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 |
# File 'app/models/article.rb', line 424 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
1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 |
# File 'app/models/article.rb', line 1028 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
598 599 600 |
# File 'app/models/article.rb', line 598 def effective_published_date published_at || created_at end |
#effective_revised_date ⇒ Object
602 603 604 |
# File 'app/models/article.rb', line 602 def effective_revised_date revised_at || published_at || created_at end |
#email_url ⇒ Object
651 652 653 |
# File 'app/models/article.rb', line 651 def email_url ARTICLE_CRM_BASE_URL + id.to_s end |
#embedded_assets ⇒ ActiveRecord::Relation<EmbeddedAsset>
158 |
# File 'app/models/article.rb', line 158 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.
1019 1020 1021 |
# File 'app/models/article.rb', line 1019 def 'Article' end |
#events_for_select ⇒ Object
483 484 485 |
# File 'app/models/article.rb', line 483 def events_for_select [["#{human_state_name.titleize} (Current)", '']] + possible_events_for_select end |
#faq_word_count ⇒ Object
210 211 212 |
# File 'app/models/article.rb', line 210 def faq_word_count word_count || ActionController::Base.helpers.(solution.to_s).split.size end |
#file_name ⇒ Object
705 706 707 |
# File 'app/models/article.rb', line 705 def file_name "#{to_param}.pdf" end |
#full_localized_solution(locale = nil, preview_mode: false) ⇒ Object
769 770 771 772 773 774 775 776 |
# File 'app/models/article.rb', line 769 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
709 710 711 712 713 714 |
# File 'app/models/article.rb', line 709 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
876 877 878 |
# File 'app/models/article.rb', line 876 def get_img_src_urls localized_solution_html_doc.xpath('//img').pluck('src') end |
#item_product_lines ⇒ ActiveRecord::Relation<ProductLine>
165 |
# File 'app/models/article.rb', line 165 has_many :item_product_lines, source: :product_lines, through: :items, class_name: 'ProductLine' |
#items ⇒ ActiveRecord::Relation<Item>
163 |
# File 'app/models/article.rb', line 163 has_and_belongs_to_many :items |
#latest_revision ⇒ Object
942 943 944 |
# File 'app/models/article.rb', line 942 def latest_revision revisions.reverse_chronological.first end |
#link_checks ⇒ ActiveRecord::Relation<LinkCheck>
168 |
# File 'app/models/article.rb', line 168 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).
765 766 767 |
# File 'app/models/article.rb', line 765 def liquid_variable_context(_locale, _preview_mode) {} end |
#localize_price(raw_html) ⇒ Object
735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 |
# File 'app/models/article.rb', line 735 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.
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 815 816 817 818 819 820 821 822 823 824 |
# File 'app/models/article.rb', line 788 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
830 831 832 |
# File 'app/models/article.rb', line 830 def localized_solution_html_doc(preview_mode: false) Nokogiri::HTML(localized_solution(nil, preview_mode: preview_mode)) end |
#normalize_friendly_id(value) ⇒ Object
614 615 616 |
# File 'app/models/article.rb', line 614 def normalize_friendly_id(value) value.to_s.parameterize(preserve_case: slug_custom.present?) end |
#ok_to_delete? ⇒ Boolean
Placeholder for can can ability
633 634 635 |
# File 'app/models/article.rb', line 633 def ok_to_delete? true end |
#original_author ⇒ Employee
113 |
# File 'app/models/article.rb', line 113 belongs_to :original_author, class_name: 'Employee', optional: true |
#page_title ⇒ Object
443 444 445 |
# File 'app/models/article.rb', line 443 def page_title title.presence || subject.presence end |
#possible_events ⇒ Object
475 476 477 |
# File 'app/models/article.rb', line 475 def possible_events state_transitions.map(&:event).sort end |
#possible_events_for_select ⇒ Object
479 480 481 |
# File 'app/models/article.rb', line 479 def possible_events_for_select possible_events.map { |evt| [evt.to_s.titleize, evt] } end |
#post_comments ⇒ ActiveRecord::Relation<PostComment>
155 |
# File 'app/models/article.rb', line 155 has_many :post_comments, -> { order(:id) }, foreign_key: :post_id, dependent: :destroy |
#precursor ⇒ Article
114 |
# File 'app/models/article.rb', line 114 belongs_to :precursor, class_name: 'Article', optional: true, inverse_of: :successors |
#preview_image ⇒ Image
117 |
# File 'app/models/article.rb', line 117 belongs_to :preview_image, class_name: 'Image', optional: true |
#primary_site_map ⇒ SiteMap?
Returns the primary site_map for this article (US locale preferred)
526 527 528 529 530 |
# File 'app/models/article.rb', line 526 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
637 638 639 |
# File 'app/models/article.rb', line 637 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
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 865 866 867 868 869 870 871 872 873 874 |
# File 'app/models/article.rb', line 839 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>
162 |
# File 'app/models/article.rb', line 162 has_and_belongs_to_many :product_categories |
#product_lines ⇒ ActiveRecord::Relation<ProductLine>
161 |
# File 'app/models/article.rb', line 161 has_and_belongs_to_many :product_lines |
#purge_edge_cache(include_indirect_associations: true, extra_urls: []) ⇒ Object
918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 |
# File 'app/models/article.rb', line 918 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
1197 1198 1199 1200 1201 1202 1203 1204 1205 |
# File 'app/models/article.rb', line 1197 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.
457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 |
# File 'app/models/article.rb', line 457 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
447 448 449 450 451 452 |
# File 'app/models/article.rb', line 447 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.
949 950 951 952 953 954 955 |
# File 'app/models/article.rb', line 949 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
=============================================================================
138 |
# File 'app/models/article.rb', line 138 has_many :revisions, class_name: 'ArticleRevision', dependent: :destroy |
#rma_reason_code ⇒ RmaReasonCode
116 |
# File 'app/models/article.rb', line 116 belongs_to :rma_reason_code, foreign_key: 'problem_code', primary_key: 'code', optional: true |
#sanitize_urls ⇒ Object
716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 |
# File 'app/models/article.rb', line 716 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
880 881 882 883 884 885 886 887 |
# File 'app/models/article.rb', line 880 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
889 890 891 |
# File 'app/models/article.rb', line 889 def schema_dot_org_structure {} end |
#schema_dot_org_structure_faq ⇒ Object
893 894 895 896 897 898 899 900 901 902 903 904 905 |
# File 'app/models/article.rb', line 893 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
675 676 677 |
# File 'app/models/article.rb', line 675 def send_creation_email CommunicationBuilder.new(resource: self, template_system_code: 'BULLETIN_CREATE').create end |
#send_update_email ⇒ Object
679 680 681 |
# File 'app/models/article.rb', line 679 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
540 541 542 |
# File 'app/models/article.rb', line 540 def seo_clicks primary_site_map&.seo_clicks end |
#seo_keywords_count ⇒ Integer?
Keywords count from Ahrefs
564 565 566 |
# File 'app/models/article.rb', line 564 def seo_keywords_count primary_site_map&.seo_keywords_count end |
#seo_last_synced_at ⇒ DateTime?
Last sync timestamp for SEO metrics
582 583 584 |
# File 'app/models/article.rb', line 582 def seo_last_synced_at primary_site_map&.seo_synced_at end |
#seo_metrics_synced? ⇒ Boolean
Check if SEO metrics are available
576 577 578 |
# File 'app/models/article.rb', line 576 def seo_metrics_synced? primary_site_map&.seo_synced_at.present? end |
#seo_top_keyword ⇒ String?
Top keyword from Ahrefs
552 553 554 |
# File 'app/models/article.rb', line 552 def seo_top_keyword primary_site_map&.seo_top_keyword end |
#seo_top_position ⇒ Integer?
Top position from Ahrefs
558 559 560 |
# File 'app/models/article.rb', line 558 def seo_top_position primary_site_map&.seo_top_position end |
#seo_traffic ⇒ Integer?
Ahrefs estimated organic traffic
546 547 548 |
# File 'app/models/article.rb', line 546 def seo_traffic primary_site_map&.seo_traffic end |
#seo_traffic_value ⇒ Integer?
Traffic value from Ahrefs
570 571 572 |
# File 'app/models/article.rb', line 570 def seo_traffic_value primary_site_map&.seo_traffic_value end |
#should_generate_new_friendly_id? ⇒ Boolean
618 619 620 |
# File 'app/models/article.rb', line 618 def should_generate_new_friendly_id? slug.nil? || slug_custom_changed? || (!published? && subject_changed?) || super end |
#site_maps ⇒ ActiveRecord::Relation<SiteMap>
156 |
# File 'app/models/article.rb', line 156 has_many :site_maps, as: :resource, dependent: :destroy |
#slug_candidates ⇒ Object
606 607 608 609 610 611 612 |
# File 'app/models/article.rb', line 606 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.
959 960 961 962 963 964 965 966 967 968 |
# File 'app/models/article.rb', line 959 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
1012 1013 1014 |
# File 'app/models/article.rb', line 1012 def solution_text parse_text_from_html(solution) end |
#subject_with_type ⇒ Object
645 646 647 |
# File 'app/models/article.rb', line 645 def subject_with_type "#{subject} (#{article_type_human})" end |
#successor_article ⇒ Article
115 |
# File 'app/models/article.rb', line 115 belongs_to :successor_article, class_name: 'Article', optional: true, inverse_of: :precursor |
#successors ⇒ ActiveRecord::Relation<Article>
153 |
# File 'app/models/article.rb', line 153 has_many :successors, class_name: 'Article', foreign_key: :precursor_id, inverse_of: :precursor |
#to_json_ld ⇒ Object
911 912 913 914 915 916 |
# File 'app/models/article.rb', line 911 def to_json_ld schema_dot_org_structure.to_s rescue StandardError => e ErrorReporting.error e logger.error e end |
#to_json_raw ⇒ Object
907 908 909 |
# File 'app/models/article.rb', line 907 def to_json_raw schema_dot_org_structure.to_json end |
#topics ⇒ ActiveRecord::Relation<Topic>
160 |
# File 'app/models/article.rb', line 160 has_and_belongs_to_many :topics |
#uploads ⇒ ActiveRecord::Relation<Upload>
167 |
# File 'app/models/article.rb', line 167 has_and_belongs_to_many :uploads, inverse_of: :articles |
#user ⇒ Employee
The current author
112 |
# File 'app/models/article.rb', line 112 belongs_to :user, class_name: 'Employee', optional: true |
#uses_revisions? ⇒ Boolean
============================================================
Revision System Methods
938 939 940 |
# File 'app/models/article.rb', line 938 def uses_revisions? is_a?(Post) end |
#visit_count_30d ⇒ Integer?
First-party visit count (30 days) - most reliable traffic metric
534 535 536 |
# File 'app/models/article.rb', line 534 def visit_count_30d primary_site_map&.visit_count_30d end |
#votes ⇒ ActiveRecord::Relation<Vote>
157 |
# File 'app/models/article.rb', line 157 has_many :votes, as: :resource, dependent: :destroy |