2016-11-15 07:56:29 -08:00
# frozen_string_literal: true
2023-02-19 21:58:28 -08:00
2017-05-01 17:14:47 -07:00
# == Schema Information
#
# Table name: tags
#
2019-08-05 10:54:29 -07:00
# id :bigint(8) not null, primary key
# name :string default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
# usable :boolean
# trendable :boolean
# listable :boolean
# reviewed_at :datetime
# requested_review_at :datetime
2019-08-17 18:45:51 -07:00
# last_status_at :datetime
2019-09-02 09:11:13 -07:00
# max_score :float
# max_score_at :datetime
2022-07-13 06:03:28 -07:00
# display_name :string
2017-05-01 17:14:47 -07:00
#
2016-11-15 07:56:29 -08:00
2016-11-04 11:12:59 -07:00
class Tag < ApplicationRecord
2023-09-13 02:22:53 -07:00
include Paginable
2024-05-02 02:40:05 -07:00
# rubocop:disable Rails/HasAndBelongsToMany
2016-11-05 07:20:05 -07:00
has_and_belongs_to_many :statuses
2018-12-06 08:36:11 -08:00
has_and_belongs_to_many :accounts
2024-05-02 02:40:05 -07:00
# rubocop:enable Rails/HasAndBelongsToMany
2018-12-06 08:36:11 -08:00
2022-07-17 04:49:29 -07:00
has_many :passive_relationships , class_name : 'TagFollow' , inverse_of : :tag , dependent : :destroy
2019-02-03 19:25:59 -08:00
has_many :featured_tags , dependent : :destroy , inverse_of : :tag
2022-07-17 04:49:29 -07:00
has_many :followers , through : :passive_relationships , source : :account
2016-11-05 07:20:05 -07:00
2023-01-03 17:12:48 -08:00
HASHTAG_SEPARATORS = " _ \ u00B7 \ u30FB \ u200c "
HASHTAG_FIRST_SEQUENCE_CHUNK_ONE = " [[:word:]_][[:word:] #{ HASHTAG_SEPARATORS } ]*[[:alpha:] #{ HASHTAG_SEPARATORS } ] "
HASHTAG_FIRST_SEQUENCE_CHUNK_TWO = " [[:word:] #{ HASHTAG_SEPARATORS } ]*[[:word:]_] "
HASHTAG_FIRST_SEQUENCE = " ( #{ HASHTAG_FIRST_SEQUENCE_CHUNK_ONE } #{ HASHTAG_FIRST_SEQUENCE_CHUNK_TWO } ) "
2023-04-12 01:06:57 -07:00
HASHTAG_LAST_SEQUENCE = '([[:word:]_]*[[:alpha:]][[:word:]_]*)'
HASHTAG_NAME_PAT = " #{ HASHTAG_FIRST_SEQUENCE } | #{ HASHTAG_LAST_SEQUENCE } "
2016-11-04 11:12:59 -07:00
2024-07-25 07:24:19 -07:00
HASHTAG_RE = %r{ (?<![=/) \ p { Alnum } ]) # ( #{ HASHTAG_NAME_PAT } ) }
2022-11-09 20:49:30 -08:00
HASHTAG_NAME_RE = / \ A( #{ HASHTAG_NAME_PAT } ) \ z /i
2023-11-14 07:33:59 -08:00
HASHTAG_INVALID_CHARS_RE = / [^[:alnum:] \ u0E47- \ u0E4E #{ HASHTAG_SEPARATORS } ] /
2022-11-09 20:49:30 -08:00
2024-01-23 01:10:11 -08:00
RECENT_STATUS_LIMIT = 1000
2022-11-09 20:49:30 -08:00
validates :name , presence : true , format : { with : HASHTAG_NAME_RE }
validates :display_name , format : { with : HASHTAG_NAME_RE }
2019-08-05 10:54:29 -07:00
validate :validate_name_change , if : - > { ! new_record? && name_changed? }
2022-07-13 06:03:28 -07:00
validate :validate_display_name_change , if : - > { ! new_record? && display_name_changed? }
2016-11-05 07:20:05 -07:00
2019-08-05 10:54:29 -07:00
scope :reviewed , - > { where . not ( reviewed_at : nil ) }
2019-08-07 08:08:30 -07:00
scope :unreviewed , - > { where ( reviewed_at : nil ) }
scope :pending_review , - > { unreviewed . where . not ( requested_review_at : nil ) }
2019-08-07 01:01:55 -07:00
scope :usable , - > { where ( usable : [ true , nil ] ) }
2019-08-17 18:45:51 -07:00
scope :listable , - > { where ( listable : [ true , nil ] ) }
2019-10-09 17:22:04 -07:00
scope :trendable , - > { Setting . trendable_by_default ? where ( trendable : [ true , nil ] ) : where ( trendable : true ) }
2021-11-25 04:07:38 -08:00
scope :not_trendable , - > { where ( trendable : false ) }
2024-04-17 03:05:38 -07:00
scope :suggestions_for_account , - > ( account ) { recently_used ( account ) . not_featured_by ( account ) }
scope :not_featured_by , - > ( account ) { where . not ( id : account . featured_tags . select ( :tag_id ) ) }
2023-02-18 03:39:00 -08:00
scope :recently_used , lambda { | account |
2023-01-03 17:12:48 -08:00
joins ( :statuses )
2024-01-23 01:10:11 -08:00
. where ( statuses : { id : account . statuses . select ( :id ) . limit ( RECENT_STATUS_LIMIT ) } )
2023-01-03 17:12:48 -08:00
. group ( :id ) . order ( Arel . sql ( 'count(*) desc' ) )
}
2021-05-07 05:33:43 -07:00
scope :matches_name , - > ( term ) { where ( arel_table [ :name ] . lower . matches ( arel_table . lower ( " #{ sanitize_sql_like ( Tag . normalize ( term ) ) } % " ) , nil , true ) ) } # Search with case-sensitive to use B-tree index
2018-12-06 08:36:11 -08:00
2021-11-18 13:02:08 -08:00
update_index ( 'tags' , :self )
2019-08-17 18:45:51 -07:00
2016-11-05 07:20:05 -07:00
def to_param
name
end
2017-03-21 18:32:27 -07:00
2022-07-13 06:03:28 -07:00
def display_name
attributes [ 'display_name' ] || name
end
2019-08-05 10:54:29 -07:00
def usable
boolean_with_default ( 'usable' , true )
end
alias usable? usable
def listable
boolean_with_default ( 'listable' , true )
end
alias listable? listable
def trendable
2019-10-08 15:30:15 -07:00
boolean_with_default ( 'trendable' , Setting . trendable_by_default )
2019-08-05 10:54:29 -07:00
end
alias trendable? trendable
def requires_review?
reviewed_at . nil?
end
def reviewed?
reviewed_at . present?
end
def requested_review?
requested_review_at . present?
end
2021-11-25 04:07:38 -08:00
def requires_review_notification?
requires_review? && ! requested_review?
2019-08-05 10:54:29 -07:00
end
2021-11-25 16:12:39 -08:00
def decaying?
max_score_at && max_score_at > = Trends . tags . options [ :max_score_cooldown ] . ago && max_score_at < 1 . day . ago
end
2018-05-27 12:45:30 -07:00
def history
2021-11-25 04:07:38 -08:00
@history || = Trends :: History . new ( 'tags' , id )
2018-05-27 12:45:30 -07:00
end
2017-03-21 18:32:27 -07:00
class << self
2019-07-27 20:59:51 -07:00
def find_or_create_by_names ( name_or_names )
2022-07-13 06:03:28 -07:00
names = Array ( name_or_names ) . map { | str | [ normalize ( str ) , str ] } . uniq ( & :first )
names . map do | ( normalized_name , display_name ) |
2023-01-03 17:12:48 -08:00
tag = matching_name ( normalized_name ) . first || create ( name : normalized_name ,
display_name : display_name . gsub ( HASHTAG_INVALID_CHARS_RE , '' ) )
2019-07-27 20:59:51 -07:00
yield tag if block_given?
tag
end
end
2019-09-27 16:02:21 -07:00
def search_for ( term , limit = 5 , offset = 0 , options = { } )
2021-05-07 05:33:43 -07:00
stripped_term = term . strip
query = Tag . listable . matches_name ( stripped_term )
query = query . merge ( matching_name ( stripped_term ) . or ( where . not ( reviewed_at : nil ) ) ) if options [ :exclude_unreviewed ]
Add type, limit, offset, min_id, max_id, account_id to search API (#10091)
* Add type, limit, offset, min_id, max_id, account_id to search API
Fix #8939
* Make the offset work on accounts and hashtags search as well
* Assure brakeman we are not doing mass assignment here
* Do not allow paginating unless a type is chosen
* Fix search query and index id field on statuses instead of created_at
2019-02-26 06:21:36 -08:00
2019-09-27 16:02:21 -07:00
query . order ( Arel . sql ( 'length(name) ASC, name ASC' ) )
. limit ( limit )
. offset ( offset )
2017-03-21 18:32:27 -07:00
end
2019-03-13 05:02:13 -07:00
def find_normalized ( name )
2019-07-27 20:59:51 -07:00
matching_name ( name ) . first
2019-03-13 05:02:13 -07:00
end
def find_normalized! ( name )
find_normalized ( name ) || raise ( ActiveRecord :: RecordNotFound )
end
2019-07-27 20:59:51 -07:00
def matching_name ( name_or_names )
2021-04-24 21:33:28 -07:00
names = Array ( name_or_names ) . map { | name | arel_table . lower ( normalize ( name ) ) }
2019-07-27 20:59:51 -07:00
if names . size == 1
where ( arel_table [ :name ] . lower . eq ( names . first ) )
else
where ( arel_table [ :name ] . lower . in ( names ) )
end
end
def normalize ( str )
2022-07-13 06:03:28 -07:00
HashtagNormalizer . new . normalize ( str )
2019-07-27 20:59:51 -07:00
end
2017-03-21 18:32:27 -07:00
end
2018-12-06 08:36:11 -08:00
private
2019-08-05 10:54:29 -07:00
def validate_name_change
errors . add ( :name , I18n . t ( 'tags.does_not_match_previous_name' ) ) unless name_was . mb_chars . casecmp ( name . mb_chars ) . zero?
end
2022-07-13 06:03:28 -07:00
def validate_display_name_change
2023-01-03 17:12:48 -08:00
unless HashtagNormalizer . new . normalize ( display_name ) . casecmp ( name . mb_chars ) . zero?
errors . add ( :display_name ,
I18n . t ( 'tags.does_not_match_previous_name' ) )
end
2022-07-13 06:03:28 -07:00
end
2016-11-04 11:12:59 -07:00
end