-
Notifications
You must be signed in to change notification settings - Fork 516
/
Copy pathtag.rb
1201 lines (1000 loc) · 42 KB
/
tag.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
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
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
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
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
require "unicode_utils/casefold"
class Tag < ApplicationRecord
include Searchable
include StringCleaner
include WorksOwner
include Wrangleable
include Rails.application.routes.url_helpers
NAME = "Tag"
# Note: the order of this array is important.
# It is the order that tags are shown in the header of a work
# (banned tags are not shown)
TYPES = ['Rating', 'ArchiveWarning', 'Category', 'Media', 'Fandom', 'Relationship', 'Character', 'Freeform', 'Banned' ]
# these tags can be filtered on
FILTERS = TYPES - ['Banned', 'Media']
# these tags show up on works
VISIBLE = TYPES - ['Media', 'Banned']
# these are tags which have been created by users
# the order is important, and it is the order in which they appear in the tag wrangling interface
USER_DEFINED = ['Fandom', 'Character', 'Relationship', 'Freeform']
def self.label_name
to_s.pluralize
end
delegate :document_type, to: :class
def document_json
TagIndexer.new({}).document(self)
end
def self.taggings_count_expiry(count)
# What we are trying to do here is work out a resonable amount of time for a work to be cached for
# This should take the number of taggings and divide it by TAGGINGS_COUNT_CACHE_DIVISOR ( defaults to 1500 )
# such that for example 1500, would be naturally be tagged for one minute while 105,000 would be cached for
# 70 minutes. However we then apply a filter such that the minimum amount of time we will cache something for
# would be TAGGINGS_COUNT_MIN_TIME ( defaults to 3 minutes ) and the maximum amount of time would be
# TAGGINGS_COUNT_MAX_TIME ( defaulting to an hour ).
expiry_time = count / (ArchiveConfig.TAGGINGS_COUNT_CACHE_DIVISOR || 1500)
[[expiry_time, (ArchiveConfig.TAGGINGS_COUNT_MIN_TIME || 3)].max, (ArchiveConfig.TAGGINGS_COUNT_MAX_TIME || 50) + count % 20 ].min
end
def taggings_count_cache_key
"/v1/taggings_count/#{id}"
end
def write_taggings_to_redis(value)
# Atomically set the value while extracting the old value.
old_redis_value = REDIS_GENERAL.getset("tag_update_#{id}_value", value).to_i
# If the value hasn't changed from the saved version or the REDIS version,
# there's no need to write an update to the database, so let's just bail
# out.
return value if value == old_redis_value && value == taggings_count_cache
# If we've reached here, then the value has changed, and we need to make
# sure that the new value is written to the database.
REDIS_GENERAL.sadd("tag_update", id)
value
end
def taggings_count=(value)
expiry_time = Tag.taggings_count_expiry(value)
# Only write to the cache if there are more than a number of uses.
Rails.cache.write(taggings_count_cache_key, value, race_condition_ttl: 10, expires_in: expiry_time.minutes) if value >= ArchiveConfig.TAGGINGS_COUNT_MIN_CACHE_COUNT
write_taggings_to_redis(value)
end
def taggings_count
cache_read = Rails.cache.read(taggings_count_cache_key)
return cache_read unless cache_read.nil?
real_value = taggings.count
self.taggings_count = real_value
real_value
end
def update_tag_cache
cache_read = Rails.cache.read(taggings_count_cache_key)
taggings_count if cache_read.nil? || (cache_read < ArchiveConfig.TAGGINGS_COUNT_MIN_CACHE_COUNT)
end
def update_counts_cache(id)
tag = Tag.find(id)
tag.taggings_count = tag.taggings.count
end
acts_as_commentable
def commentable_name
self.name
end
# For a tag, the commentable owners are the wranglers of the fandom(s)
def commentable_owners
# if the tag is a fandom, grab its wranglers or the wranglers of its canonical merger
if self.is_a?(Fandom)
self.canonical? ? self.wranglers : (self.merger_id ? self.merger.wranglers : [])
# if the tag is any other tag, try to grab all the wranglers of all its parent fandoms, if applicable
else
begin
self.fandoms.collect {|f| f.wranglers}.compact.flatten.uniq
rescue
[]
end
end
end
has_many :mergers, foreign_key: 'merger_id', class_name: 'Tag'
belongs_to :merger, class_name: 'Tag'
belongs_to :fandom
belongs_to :media
belongs_to :last_wrangler, polymorphic: true
has_many :filter_taggings, foreign_key: 'filter_id', dependent: :destroy
has_many :filtered_works, through: :filter_taggings, source: :filterable, source_type: 'Work'
has_many :filtered_external_works, through: :filter_taggings, source: :filterable, source_type: "ExternalWork"
has_many :filtered_collections, through: :filter_taggings, source: :filterable, source_type: "Collection"
has_one :filter_count, foreign_key: 'filter_id'
has_many :direct_filter_taggings,
-> { where(inherited: 0) },
class_name: "FilterTagging",
foreign_key: 'filter_id'
# not used anymore? has_many :direct_filtered_works, through: :direct_filter_taggings, source: :filterable, source_type: 'Work'
has_many :common_taggings, foreign_key: 'common_tag_id', dependent: :destroy
has_many :child_taggings, class_name: 'CommonTagging', as: :filterable
has_many :children, through: :child_taggings, source: :common_tag
has_many :parents,
through: :common_taggings,
source: :filterable,
source_type: 'Tag',
before_remove: :destroy_common_tagging,
after_remove: :update_wrangler
has_many :meta_taggings, foreign_key: 'sub_tag_id', dependent: :destroy
has_many :meta_tags, through: :meta_taggings, source: :meta_tag, before_remove: :destroy_meta_tagging
has_many :sub_taggings, class_name: 'MetaTagging', foreign_key: 'meta_tag_id', dependent: :destroy
has_many :sub_tags, through: :sub_taggings, source: :sub_tag, before_remove: :destroy_sub_tagging
has_many :direct_meta_tags, -> { where('meta_taggings.direct = 1') }, through: :meta_taggings, source: :meta_tag
has_many :direct_sub_tags, -> { where('meta_taggings.direct = 1') }, through: :sub_taggings, source: :sub_tag
has_many :taggings, as: :tagger
has_many :works, through: :taggings, source: :taggable, source_type: 'Work'
has_many :collections, through: :taggings, source: :taggable, source_type: "Collection"
has_many :bookmarks, through: :taggings, source: :taggable, source_type: 'Bookmark'
has_many :external_works, through: :taggings, source: :taggable, source_type: 'ExternalWork'
has_many :approved_collections, through: :filtered_works
has_many :favorite_tags, dependent: :destroy
has_many :set_taggings, dependent: :destroy
has_many :tag_sets, through: :set_taggings
has_many :owned_tag_sets, through: :tag_sets
has_many :tag_set_associations, dependent: :destroy
has_many :parent_tag_set_associations, class_name: 'TagSetAssociation', foreign_key: 'parent_tag_id', dependent: :destroy
validates_presence_of :name
validates :name, uniqueness: true
validates_length_of :name, minimum: 1, message: "cannot be blank."
validates_length_of :name,
maximum: ArchiveConfig.TAG_MAX,
message: "^Tag name '%{value}' is too long -- try using less than %{count} characters or using commas to separate your tags."
validates_format_of :name,
with: /\A[^,*<>^{}=`\\%]+\z/,
message: "^Tag name '%{value}' cannot include the following restricted characters: , ^ * < > { } = ` \\ %"
validates_presence_of :sortable_name
validate :unwrangleable_status
def unwrangleable_status
return unless unwrangleable?
self.errors.add(:unwrangleable, "can't be set on a canonical or synonymized tag.") if canonical? || merger_id.present?
self.errors.add(:unwrangleable, "can't be set on an unsorted tag.") if is_a?(UnsortedTag)
end
before_validation :check_synonym
def check_synonym
if !self.new_record? && self.name_changed?
# ordinary wranglers can change case and accents but not punctuation or the actual letters in the name
# admins can change tags with no restriction
unless User.current_user.is_a?(Admin) || only_case_changed?
self.errors.add(:name, "can only be changed by an admin.")
end
end
if self.merger_id
if self.canonical?
self.errors.add(:base, "A canonical can't be a synonym")
end
if self.merger_id == self.id
self.errors.add(:base, "A tag can't be a synonym of itself.")
end
unless self.merger.class == self.class
self.errors.add(:base, "A tag can only be a synonym of a tag in the same category as itself.")
end
end
end
before_validation :squish_name
def squish_name
self.name = name.squish if self.name
end
before_validation :set_sortable_name
def set_sortable_name
if sortable_name.blank?
self.sortable_name = remove_articles_from_string(self.name)
end
end
after_update :queue_flush_work_cache
def queue_flush_work_cache
async_after_commit(:flush_work_cache) if saved_change_to_name? || saved_change_to_type?
end
def flush_work_cache
self.work_ids.each do |work|
Work.expire_work_blurb_version(work)
end
end
before_save :set_last_wrangler
def set_last_wrangler
unless User.current_user.nil?
self.last_wrangler = User.current_user
end
end
def update_wrangler(tag)
unless User.current_user.nil?
self.update(last_wrangler: User.current_user)
end
end
after_save :check_type_changes, if: :saved_change_to_type?
def check_type_changes
return if type_before_last_save.nil?
retyped = Tag.find(self.id)
# Clean up invalid CommonTaggings.
retyped.common_taggings.destroy_invalid
retyped.child_taggings.destroy_invalid
# If the tag has just become a Fandom, it needs the Uncategorized media
# added to it manually (the after_save hook on Fandom won't take effect,
# since it's not a Fandom yet)
retyped.add_media_for_uncategorized if retyped.is_a?(Fandom)
end
# Callback for has_many :parents.
# Destroy the common tagging so we trigger CommonTagging's callbacks when a
# parent is removed. We're specifically interested in the update_search
# callback that will reindex the tag and return it to the unwrangled bin.
def destroy_common_tagging(parent)
self.common_taggings.find_by(filterable_id: parent.id).try(:destroy)
end
scope :id_only, -> { select("tags.id") }
scope :canonical, -> { where(canonical: true) }
scope :noncanonical, -> { where(canonical: false) }
scope :nonsynonymous, -> { noncanonical.where(merger_id: nil) }
scope :synonymous, -> { noncanonical.where("merger_id IS NOT NULL") }
scope :unfilterable, -> { nonsynonymous.where(unwrangleable: false) }
scope :unwrangleable, -> { where(unwrangleable: true) }
# we need to manually specify a LEFT JOIN instead of just joins(:common_taggings or :meta_taggings) here because
# what we actually need are the empty rows in the results
scope :unwrangled, -> { joins("LEFT JOIN `common_taggings` ON common_taggings.common_tag_id = tags.id").where("unwrangleable = 0 AND common_taggings.id IS NULL") }
scope :in_use, -> { where("canonical = 1 OR taggings_count_cache > 0") }
scope :first_class, -> { joins("LEFT JOIN `meta_taggings` ON meta_taggings.sub_tag_id = tags.id").where("meta_taggings.id IS NULL") }
# Tags that have sub tags
scope :meta_tag, -> { joins(:sub_taggings).where("meta_taggings.id IS NOT NULL").group("tags.id") }
# Tags that don't have sub tags
scope :non_meta_tag, -> { joins(:sub_taggings).where("meta_taggings.id IS NULL").group("tags.id") }
scope :by_popularity, -> { order('taggings_count_cache DESC') }
scope :by_name, -> { order('sortable_name ASC') }
scope :by_date, -> { order('created_at DESC') }
scope :visible, -> { where('type in (?)', VISIBLE).by_name }
scope :by_pseud, lambda {|pseud|
joins(works: :pseuds).
where(pseuds: {id: pseud.id})
}
scope :by_type, lambda {|*types| where(types.first.blank? ? "" : {type: types.first})}
scope :with_type, lambda {|type| where({type: type}) }
# This will return all tags that have one of the given tags as a parent
scope :with_parents, lambda {|parents|
joins(:common_taggings).where("filterable_id in (?)", parents.first.is_a?(Integer) ? parents : (parents.respond_to?(:pluck) ? parents.pluck(:id) : parents.collect(&:id)))
}
scope :with_no_parents, -> {
joins("LEFT JOIN common_taggings ON common_taggings.common_tag_id = tags.id").
where("filterable_id IS NULL")
}
scope :starting_with, lambda {|letter| where('SUBSTR(name,1,1) = ?', letter)}
scope :visible_to_all_with_count, -> {
joins(:filter_count).
select("tags.*, filter_counts.public_works_count as count").
where('filter_counts.public_works_count > 0 AND tags.canonical = 1')
}
scope :visible_to_registered_user_with_count, -> {
joins(:filter_count).
select("tags.*, filter_counts.unhidden_works_count as count").
where('filter_counts.unhidden_works_count > 0 AND tags.canonical = 1')
}
scope :public_top, lambda { |tag_count|
visible_to_all_with_count.
limit(tag_count).
order('filter_counts.public_works_count DESC')
}
scope :unhidden_top, lambda { |tag_count|
visible_to_registered_user_with_count.
limit(tag_count).
order('filter_counts.unhidden_works_count DESC')
}
scope :popular, -> {
(User.current_user.is_a?(Admin) || User.current_user.is_a?(User)) ?
visible_to_registered_user_with_count.order('filter_counts.unhidden_works_count DESC') :
visible_to_all_with_count.order('filter_counts.public_works_count DESC')
}
scope :random, -> {
(User.current_user.is_a?(Admin) || User.current_user.is_a?(User)) ?
visible_to_registered_user_with_count.random_order :
visible_to_all_with_count.random_order
}
scope :with_count, -> {
(User.current_user.is_a?(Admin) || User.current_user.is_a?(User)) ?
visible_to_registered_user_with_count : visible_to_all_with_count
}
scope :for_collections, lambda { |collections|
joins(filtered_works: :approved_collection_items).merge(Work.posted)
.where("collection_items.collection_id IN (?)", collections.collect(&:id))
}
scope :for_collection, lambda { |collection| for_collections([collection]) }
scope :for_collections_with_count, lambda { |collections|
for_collections(collections).
select("tags.*, count(tags.id) as count").
group(:id).
order(:name)
}
scope :with_scoped_count, lambda {
select("tags.*, count(tags.id) as count").
group(:id)
}
scope :by_relationships, lambda {|relationships|
select("DISTINCT tags.*").
joins(:children).
where('children_tags.id IN (?)', relationships.collect(&:id))
}
# Get the tags for a challenge's signups, checking both the main tag set
# and the optional tag set for each prompt
def self.in_challenge(collection, prompt_type=nil)
['', 'optional_'].map { |tag_set_type|
join = "INNER JOIN set_taggings ON (tags.id = set_taggings.tag_id)
INNER JOIN tag_sets ON (set_taggings.tag_set_id = tag_sets.id)
INNER JOIN prompts ON (prompts.#{tag_set_type}tag_set_id = tag_sets.id)
INNER JOIN challenge_signups ON (prompts.challenge_signup_id = challenge_signups.id)"
tags = self.joins(join).where("challenge_signups.collection_id = ?", collection.id)
tags = tags.where("prompts.type = ?", prompt_type) if prompt_type.present?
tags
}.flatten.compact.uniq
end
scope :requested_in_challenge, lambda {|collection|
in_challenge(collection, 'Request')
}
scope :offered_in_challenge, lambda {|collection|
in_challenge(collection, 'Offer')
}
# Code for delayed jobs:
include AsyncWithActiveJob
self.async_job_class = TagMethodJob
# Class methods
def self.in_prompt_restriction(restriction)
joins("INNER JOIN set_taggings ON set_taggings.tag_id = tags.id
INNER JOIN tag_sets ON tag_sets.id = set_taggings.tag_set_id
INNER JOIN owned_tag_sets ON owned_tag_sets.tag_set_id = tag_sets.id
INNER JOIN owned_set_taggings ON owned_set_taggings.owned_tag_set_id = owned_tag_sets.id
INNER JOIN prompt_restrictions ON (prompt_restrictions.id = owned_set_taggings.set_taggable_id AND owned_set_taggings.set_taggable_type = 'PromptRestriction')").
where("prompt_restrictions.id = ?", restriction.id)
end
def self.by_name_without_articles(fieldname = "name")
fieldname = "name" unless fieldname.match(/^([\w]+\.)?[\w]+$/)
order(Arel.sql("case when lower(substring(#{fieldname} from 1 for 4)) = 'the ' then substring(#{fieldname} from 5)
when lower(substring(#{fieldname} from 1 for 2)) = 'a ' then substring(#{fieldname} from 3)
when lower(substring(#{fieldname} from 1 for 3)) = 'an ' then substring(#{fieldname} from 4)
else #{fieldname}
end"))
end
def self.in_tag_set(tag_set)
if tag_set.is_a?(OwnedTagSet)
joins(:set_taggings).where("set_taggings.tag_set_id = ?", tag_set.tag_set_id)
else
joins(:set_taggings).where("set_taggings.tag_set_id = ?", tag_set.id)
end
end
# gives you [parent_name, child_name], [parent_name, child_name], ...
def self.parent_names(parent_type = 'fandom')
joins(:parents).where("parents_tags.type = ?", parent_type.capitalize).
select("parents_tags.name as parent_name, tags.name as child_name").
by_name_without_articles("parent_name").
by_name_without_articles("child_name")
end
# Because this can be called by a gigantor tag set and all we need are names not objects,
# we do an end-run around ActiveRecord and just get the results straight from the db, but
# we borrow the sql from parent_names above
# returns a hash[parent_name] = child_names
def self.names_by_parent(child_relation, parent_type = 'fandom')
hash = {}
results = ActiveRecord::Base.connection.execute(child_relation.parent_names(parent_type).to_sql)
results.each {|row| hash[row.first] ||= Array.new; hash[row.first] << row.second}
hash
end
# Used for associations, such as work.fandoms.string
# Yields a comma-separated list of tag names
def self.string
all.map{|tag| tag.name}.join(ArchiveConfig.DELIMITER_FOR_OUTPUT)
end
# Use the tag name in urls and escape url-unfriendly characters
def to_param
# can't find a tag with a name that hasn't been saved yet
saved_name = self.name_changed? ? self.name_was : self.name
saved_name.gsub('/', '*s*').gsub('&', '*a*').gsub('.', '*d*').gsub('?', '*q*').gsub('#', '*h*')
end
def display_name
name
end
# Make sure that the global ID doesn't depend on the type, so that we don't
# experience errors when switching types:
def to_global_id(options = {})
GlobalID.create(becomes(Tag), options)
end
## AUTOCOMPLETE
# set up autocomplete and override some methods
include AutocompleteSource
def autocomplete_prefixes
prefixes = [ "autocomplete_tag_#{type.downcase}", "autocomplete_tag_all" ]
prefixes
end
def add_to_autocomplete(score = nil)
if eligible_for_fandom_autocomplete?
parents.each do |parent|
add_to_fandom_autocomplete(parent, score) if parent.is_a?(Fandom)
end
end
super
end
def add_to_fandom_autocomplete(fandom, score = nil)
score ||= autocomplete_score
REDIS_AUTOCOMPLETE.zadd(self.transliterate("autocomplete_fandom_#{fandom.name.downcase}_#{type.downcase}"), score, autocomplete_value)
end
def remove_from_autocomplete
super
return unless was_eligible_for_fandom_autocomplete?
parents.each do |parent|
remove_from_fandom_autocomplete(parent) if parent.is_a?(Fandom)
end
end
def remove_from_fandom_autocomplete(fandom)
REDIS_AUTOCOMPLETE.zrem(self.transliterate("autocomplete_fandom_#{fandom.name.downcase}_#{type.downcase}"), autocomplete_value)
end
def eligible_for_fandom_autocomplete?
(self.is_a?(Character) || self.is_a?(Relationship)) && canonical
end
def was_eligible_for_fandom_autocomplete?
(self.is_a?(Character) || self.is_a?(Relationship)) && (canonical || canonical_before_last_save)
end
def remove_stale_from_autocomplete
super
return unless was_eligible_for_fandom_autocomplete?
parents.each do |parent|
REDIS_AUTOCOMPLETE.zrem(self.transliterate("autocomplete_fandom_#{parent.name.downcase}_#{type.downcase}"), autocomplete_value_before_last_save) if parent.is_a?(Fandom)
end
end
def self.parse_autocomplete_value(current_autocomplete_value)
current_autocomplete_value.split(AUTOCOMPLETE_DELIMITER, 2)
end
def autocomplete_score
taggings_count_cache
end
# look up tags that have been wrangled into a given fandom
def self.autocomplete_fandom_lookup(options = {})
options.reverse_merge!({term: "", tag_type: "character", fandom: "", fallback: true})
search_param = options[:term]
tag_type = options[:tag_type]
fandoms = Tag.get_search_terms(options[:fandom])
# fandom sets are too small to bother breaking up
# we're just getting ALL the tags in the set(s) for the fandom(s) and then manually matching
results = []
fandoms.each do |single_fandom|
if search_param.blank?
# just return ALL the characters
results += REDIS_AUTOCOMPLETE.zrevrange(self.transliterate("autocomplete_fandom_#{single_fandom}_#{tag_type}"), 0, -1)
else
search_regex = Tag.get_search_regex(search_param)
results += REDIS_AUTOCOMPLETE.zrevrange(self.transliterate("autocomplete_fandom_#{single_fandom}_#{tag_type}"), 0, -1).select { |tag| tag.match(search_regex) }
end
end
if options[:fallback] && results.empty? && search_param.length > 0
# do a standard tag lookup instead
Tag.autocomplete_lookup(search_param: search_param, autocomplete_prefix: "autocomplete_tag_#{tag_type}")
else
results
end
end
## END AUTOCOMPLETE
# Substitute characters that are particularly prone to cause trouble in urls
def self.find_by_name(string)
return unless string.is_a? String
string = string.gsub(
/\*[sadqh]\*/,
'*s*' => '/',
'*a*' => '&',
'*d*' => '.',
'*q*' => '?',
'*h*' => '#'
)
self.where('tags.name = ?', string).first
end
# If a tag by this name exists in another class, add a suffix to disambiguate them
def self.find_or_create_by_name(new_name)
if new_name && new_name.is_a?(String)
new_name.squish!
tag = Tag.find_by_name(new_name)
# if the tag exists and has the proper class, or it is an unsorted tag and it can be sorted to the self class
if tag && (tag.class == self || tag.class == UnsortedTag && tag = tag.recategorize(self.to_s))
tag
elsif tag
self.find_or_create_by_name(new_name + " - " + self.to_s)
else
self.create(name: new_name, type: self.to_s)
end
end
end
def self.create_canonical(name, adult=false)
tag = self.find_or_create_by_name(name)
raise "how did this happen?" unless tag
tag.update_attribute(:canonical,true)
tag.update_attribute(:adult, adult)
raise "how did this happen?" unless tag.canonical?
return tag
end
# Inherited tag classes can set this to indicate types of tags with which they may have a parent/child
# relationship (ie. media: parent, fandom: child; fandom: parent, character: child)
def parent_types
[]
end
def child_types
[]
end
# Instance methods that are common to all subclasses (may be overridden in the subclass)
def unfilterable?
!(self.canonical? || self.unwrangleable? || self.merger_id.present? || self.mergers.any?)
end
# Returns true if a tag has been used in posted works
def has_posted_works?
self.works.posted.any?
end
# sort tags by name
def <=>(another_tag)
name.downcase <=> another_tag.name.downcase
end
# only allow changing the tag type for unwrangled tags not used in any tag sets or on any works
def can_change_type?
self.unfilterable? && self.set_taggings.count == 0 && self.works.count == 0
end
# tags having their type changed need to be reloaded to be seen as an instance of the proper subclass
def recategorize(new_type)
self.update_attribute(:type, new_type)
# return a new instance of the tag, with the correct class
Tag.find(self.id)
end
#### FILTERING ####
before_update :reindex_associated_for_name_or_type_change
def reindex_associated_for_name_or_type_change
return unless name_changed? || type_changed?
reindex_pseuds = (type == "Fandom") || (type_was == "Fandom")
async_after_commit(:reindex_associated, reindex_pseuds)
end
# Reindex anything even remotely related to this tag. This is overkill in
# most cases, but necessary when something fundamental like the name or type
# of a tag has changed.
def reindex_associated(reindex_pseuds = false)
works.reindex_all
external_works.reindex_all
bookmarks.reindex_all
filtered_works.reindex_all
filtered_external_works.reindex_all
Series.joins(works: :taggings)
.merge(self.taggings).reindex_all
Series.joins(works: :filter_taggings)
.merge(self.filter_taggings).reindex_all
# We only want to reindex pseuds if this tag is a Fandom. Unfortunately, we
# can't just check the current type, because tags can change type, and we'd
# still need to reindex if the old type was Fandom. So we have an option to
# control it.
if reindex_pseuds
Pseud.joins(works: :filter_taggings)
.merge(self.direct_filter_taggings).reindex_all
end
end
# The version of the tag that should be used for filtering, if any
def filter
self.canonical? ? self : ((self.merger && self.merger.canonical?) ? self.merger : nil)
end
# Update filters for all works and external works directly tagged with this
# tag.
def update_filters_for_taggables
works.update_filters
external_works.update_filters
collections.update_filters
end
# Update filters for all works and external works that already have this tag
# as one of their filters.
def update_filters_for_filterables
filtered_works.update_filters
filtered_external_works.update_filters
filtered_collections.update_filters
end
# When canonical or merger_id changes, only the items directly tagged with
# this tag need their filters updated, so we queue up a call to
# update_filters_for_taggables after commit.
#
# Note that when a tag becomes non-canonical, all of its filter-taggings need
# to be deleted. But when a tag becomes non-canonical, all of its mergers and
# sub-tags will be deleted, which will result in the necessary items having
# their filters fixed.
after_update :update_filters_for_canonical_or_merger_change
def update_filters_for_canonical_or_merger_change
return unless saved_change_to_canonical? || saved_change_to_merger_id?
async_after_commit(:update_filters_for_taggables)
end
# Recalculate the inherited metatags for this tag, and once those changes
# are committed, update the filters for every work or external work that's
# filter-tagged with this tag.
def update_inherited_meta_tags
MetaTagging.transaction do
InheritedMetaTagUpdater.new(self).update
sub_tags.find_each do |sub_tag|
InheritedMetaTagUpdater.new(sub_tag).update
end
end
async_after_commit(:update_filters_for_filterables)
end
# When deleting a metatag, we destroy the meta-tagging first to trigger the
# appropriate destroy callback.
def destroy_meta_tagging(meta_tag)
meta_taggings.find_by(meta_tag: meta_tag)&.destroy
end
# When deleting a subtag, we destroy the sub-tagging first to trigger the
# appropriate destroy callback.
def destroy_sub_tagging(sub_tag)
sub_taggings.find_by(sub_tag: sub_tag)&.destroy
end
def reset_filter_count
FilterCount.enqueue_filter(filter)
end
#### END FILTERING ####
# methods for counting visible
def visible_works_count
User.current_user.nil? ? self.works.posted.unhidden.unrestricted.count : self.works.posted.unhidden.count
end
def visible_bookmarks_count
self.bookmarks.is_public.count
end
def visible_external_works_count
self.external_works.where(hidden_by_admin: false).count
end
def banned
self.is_a?(Banned)
end
def synonyms
self.canonical? ? self.mergers : [self.merger] + self.merger.mergers - [self]
end
# Add a common tagging association
def add_association(tag)
build_association(tag).save
end
def has_parent?(tag)
self.common_taggings.where(filterable_id: tag.id).count > 0
end
def has_child?(tag)
self.child_taggings.where(common_tag_id: tag.id).count > 0
end
def associations_to_remove; @associations_to_remove ? @associations_to_remove : []; end
def associations_to_remove=(taglist)
taglist.reject {|tid| tid.blank?}.each do |tag_id|
remove_association(tag_id)
end
end
# Determine how two tags are related and divorce them from each other
def remove_association(tag_id)
tag = Tag.find(tag_id)
if tag.class == self.class
tag.update(merger: nil) if tag.merger == self
meta_taggings.where(direct: true, meta_tag: tag).destroy_all
sub_taggings.where(direct: true, sub_tag: tag).destroy_all
else
common_taggings.where(filterable: tag).destroy_all
child_taggings.where(common_tag: tag).destroy_all
end
tag.touch
self.touch
end
# When canonical or merger is changed, we need to make sure that the
# associations (parents, children, metatags, mergers) are fixed. Note that
# these are all async calls, so we use async_after_commit to reduce the
# likelihood of issues with stale data.
before_update :update_associations_for_canonical_or_merger_change
def update_associations_for_canonical_or_merger_change
if (merger_id_changed? && merger_id.present?) ||
(canonical_changed? && !canonical?)
async_after_commit(:transfer_or_remove_favorite_tags)
async_after_commit(:transfer_or_remove_associations)
end
end
# Make it possible to go from a synonym to a canonical in one step.
before_validation :reset_merger_when_becoming_canonical
def reset_merger_when_becoming_canonical
return unless self.canonical_changed? && self.canonical?
self.merger_id = nil
end
# If this tag has a canonical merger, transfer associations to the merger.
# Then, regardless of whether it has a merger, delete all canonical
# associations (i.e. meta taggings, and associations where this tag is the
# parent).
def transfer_or_remove_associations
transaction do
# Try to prevent some concurrency issues.
lock!
# Abort if the tag has changed back to being canonical between the time
# this was enqueued and the time it ran.
return if self.canonical?
add_associations_to_merger if self.merger&.canonical?
self.mergers.find_each { |tag| tag.update(merger_id: nil) }
self.child_taggings.destroy_all
self.sub_taggings.destroy_all
self.meta_taggings.destroy_all
end
end
# When we make this tag a synonym of another canonical tag, we want to move
# all the associations this tag has (subtags, metatags, etc) over to that
# canonical tag.
#
# The callbacks that occur when changing the associations will trigger the
# necessary reindexing, so we don't need to call extra reindexing code here.
def add_associations_to_merger
self.parents.find_each do |tag|
self.merger.add_association(tag)
end
self.children.find_each do |tag|
self.merger.add_association(tag)
end
self.mergers.find_each { |tag| tag.update(merger: self.merger) }
merger.parents.where(type: %w[Media Fandom]).find_each do |tag|
self.add_association(tag)
end
self.direct_meta_tags.find_each do |tag|
meta_tagging = self.merger.meta_taggings.find_or_initialize_by(meta_tag: tag)
meta_tagging.update(direct: true)
end
self.direct_sub_tags.find_each do |tag|
sub_tagging = self.merger.sub_taggings.find_or_initialize_by(sub_tag: tag)
sub_tagging.update(direct: true)
end
end
# If this tag has a canonical merger, move all favorite tags to the merger.
# Otherwise, delete all favorite tags.
def transfer_or_remove_favorite_tags
if merger&.canonical
favorite_tags.find_each do |ft|
ft.update(tag_id: merger_id)
end
end
# We perform this after the if (instead of as a separate branch) because
# updating the tag_id can fail if the user has both this tag and its merger
# as favorite tags. So we want to clean up any failures, which just so
# happens to be exactly the same thing we need to do if there's no
# canonical merger to transfer the favorite tags to.
favorite_tags.find_each(&:destroy)
end
attr_reader :meta_tag_string, :sub_tag_string, :merger_string
# Uses the value of parent_types to determine whether the passed-in tag
# should be added as a parent or a child, and then generates the association
# (if it doesn't already exist). If it does already exist, returns the
# existing CommonTagging object.
def build_association(tag)
if parent_types.include?(tag&.type)
common_taggings.find_or_initialize_by(filterable: tag)
else
child_taggings.find_or_initialize_by(common_tag: tag)
end
end
# Splits up the passed-in string into a sequence of individual tag names,
# then finds (and yields) the tag for each. Used by add_association_string,
# meta_tag_string=, and sub_tag_string=.
def parse_tag_string(tag_string)
tag_string.split(",").map(&:squish).each do |name|
yield name, Tag.find_by_name(name)
end
end
# Try to create new associations with the tags of type tag_type whose names
# are listed in tag_string.
def add_association_string(tag_type, tag_string)
parse_tag_string(tag_string) do |name, parent|
prefix = "Cannot add association to '#{name}':"
if parent && parent.type != tag_type
errors.add(:base, "#{prefix} #{parent.type} added in #{tag_type} field.")
else
association = build_association(parent)
save_and_gather_errors(association, prefix)
end
end
end
# Save an item to the database, if it's valid. If it's invalid, read in the
# error messages from the item and copy them over to this tag.
def save_and_gather_errors(item, prefix)
return unless item.new_record? || item.changed?
return if item.valid? && item.save
item.errors.full_messages.each do |message|
errors.add(:base, "#{prefix} #{message}")
end
end
# Find and destroy all invalid CommonTaggings and MetaTaggings associated
# with this tag.
def destroy_invalid_associations
common_taggings.destroy_invalid
child_taggings.destroy_invalid
meta_taggings.destroy_invalid
sub_taggings.destroy_invalid
end
# defines fandom_string=, media_string=, character_string=, relationship_string=, freeform_string=
%w(Fandom Media Character Relationship Freeform).each do |tag_type|
attr_reader "#{tag_type.downcase}_string"
define_method("#{tag_type.downcase}_string=") do |tag_string|
add_association_string(tag_type, tag_string)
end
end
def meta_tag_string=(tag_string)
parse_tag_string(tag_string) do |name, parent|
meta_tagging = meta_taggings.find_or_initialize_by(meta_tag: parent)
meta_tagging.direct = true
save_and_gather_errors(meta_tagging, "Invalid metatag '#{name}':")
end
end
def sub_tag_string=(tag_string)
parse_tag_string(tag_string) do |name, sub|
sub_tagging = sub_taggings.find_or_initialize_by(sub_tag: sub)
sub_tagging.direct = true
save_and_gather_errors(sub_tagging, "Invalid subtag '#{name}':")
end
end
def syn_string
self.merger.name if self.merger
end
# Make this tag a synonym of another tag -- tag_string is the name of the other tag (which should be canonical)
# NOTE for potential confusion
# "merger" is the canonical tag of which this one will be a synonym
# "mergers" are the tags which are (currently) synonyms of THIS one
def syn_string=(tag_string)
# If the tag_string is blank, our tag should be given no merger
if tag_string.blank?
self.merger_id = nil
return
end
new_merger = Tag.find_by(name: tag_string)
# Bail out if the new merger is the same as the current merger
return if new_merger && new_merger == self.merger
# Return an error if a non-admin tries to make a canonical into a synonym