Class: Source::Bibtex

Inherits:
Source show all
Extended by:
SoftValidationExtensions::Klass
Includes:
Shared::OriginRelationship, Shared::QueryBatchUpdate, SoftValidationExtensions::Instance
Defined in:
app/models/source/bibtex.rb

Overview

Bibtex - Subclass of Source that represents most references.

Cached values are formatted using the 'zootaxa' style from 'csl/styles'

TaxonWorks(TW) relies on the bibtex-ruby gem to input or output BibTeX bibliographies, and has a strict list of required fields. TW itself only requires that :bibtex_type be valid and that one of the attributes in TW_REQUIRED_FIELDS be defined. This allows a rapid input of incomplete data, but also means that not all TW Source::Bibtex objects can be added to a BibTeX bibliography.

The following information is taken from BibTeXing, by Oren Patashnik, February 8, 1988 ftp.math.purdue.edu/mirrors/ctan.org/biblio/bibtex/contrib/doc/btxdoc.pdf (and snippets are cut from this document for the attribute descriptions)

BibTeX fields in a BibTex bibliography are treated in one of three ways:

REQUIRED

Omitting the field will produce a warning message and, rarely, a badly formatted bibliography entry. If the required information is not meaningful, you are using the wrong entry type. However, if the required information is meaningful but, say, already included is some other field, simply ignore the warning.

OPTIONAL

The field’s information will be used if present, but can be omitted

without causing any formatting problems. You should include the optional
     field if it will help the reader.
IGNORED

The field is ignored. BibTEX ignores any field that is not required or

optional, so you can include any fields you want in a bib file entry. It’s a

good idea to put all relevant information about a reference in its bib file
entry - even information that may never appear in the bibliography.

Dates in Source Bibtex:

It is common for there two be two (or more) dates associated with the origin of a source:
  1) If you only have reference to a single value, it goes in year (month, day)
  2) If you have reference to two year values, the actual year of publication goes in year, and the stated year of publication goes in stated_year.
  3) If you have month or day publication, they go in month or day.

We do not track stated_month or stated_day if they are present in addition to actual month and actual day.

BibTeX has month.
BibTeX does not have day.

Author:

  • Elizabeth Frank <eef@illinois.edu> INHS University of IL

  • Matt Yoder

Defined Under Namespace

Modules: SoftValidationExtensions

Constant Summary collapse

DEFAULT_CSL_STYLE =
'taxonworks'.freeze
GRAPH_ENTRY_POINTS =
[:origin_relationships].freeze
BIBTEX_REQUIRED_FIELDS =

Used in soft validation

{
  article: [:author, :title, :journal, :year],
  book: [:author, :editor, :title, :publisher, :year],
  booklet: [:title],
  conference: [:author, :title, :booktitle, :year],
  inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
  incollection: [:author, :title, :booktitle, :publisher, :year],
  inproceedings:  [:author, :title, :booktitle, :year],
  manual: [:title],
  mastersthesis: [:author, :title, :school, :year],
  misc: [],
  phdthesis: [:author, :title, :school, :year],
  proceedings: [:title, :year],
  techreport: [:author,:title,:institution, :year],
  unpublished: [:author, :title, :note]
}.freeze
TW_REQUIRED_FIELDS =

TW required fields (must have one of these fields filled in) either year or stated_year is acceptable

[:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze
IGNORE_SIMILAR =
[:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
IGNORE_IDENTICAL =
IGNORE_SIMILAR.dup.freeze

Constants included from SoftValidationExtensions::Klass

SoftValidationExtensions::Klass::VALIDATIONS

Constants inherited from Source

ALTERNATE_VALUES_FOR

Constants included from SoftValidation

SoftValidation::ANCESTORS_WITH_SOFT_VALIDATIONS

Instance Attribute Summary collapse

Attributes inherited from Source

#cached, #cached_author_string, #no_year_suffix_validation, #serial_id

Attributes included from Housekeeping::Users

#by

Class Method Summary collapse

Instance Method Summary collapse

Methods included from SoftValidationExtensions::Instance

#sv_contains_a_writer, #sv_duplicate_title, #sv_electronic_only, #sv_has_authors, #sv_has_booktitle, #sv_has_chapter_or_pages, #sv_has_institution, #sv_has_note, #sv_has_publisher, #sv_has_school, #sv_has_some_type_of_year, #sv_has_title, #sv_has_year, #sv_is_article_missing_journal, #sv_match_fields?, #sv_missing_roles

Methods included from Shared::OriginRelationship

#new_objects, #old_objects, #reject_origin_relationships, #set_origin

Methods included from Shared::QueryBatchUpdate

#query_update

Methods inherited from Source

#author_year, batch_create, batch_preview, #cited_objects, #clone, #is_bibtex?, #is_in_project?, #nomenclature_date, #reject_project_sources, select_optimized, #sv_fix_cached_names, #sv_fix_stated_year, #sv_html_tags, #sv_stated_year, used_recently

Methods included from Shared::IsData

#errors_excepting, #full_error_messages_excepting, #identical, #is_community?, #is_destroyable?, #is_editable?, #is_in_use?, #is_in_users_projects?, #metamorphosize, #similar

Methods included from SoftValidation

#clear_soft_validations, #fix_for, #fix_soft_validations, #soft_fixed?, #soft_valid?, #soft_validate, #soft_validated?, #soft_validations, #soft_validators

Methods included from Shared::HasPapertrail

#attribute_updated, #attribute_updater

Methods included from Shared::Tags

#reject_tags, #tag_with, #tagged?, #tagged_with?

Methods included from Shared::Notes

#concatenated_notes_string, #reject_notes

Methods included from Shared::Identifiers

#dwc_occurrence_id, #identified?, #next_by_identifier, #previous_by_identifier, #reject_identifiers, #uri, #uuid

Methods included from Shared::Documentation

#document_array=, #documented?, #reject_documentation, #reject_documents

Methods included from Shared::DataAttributes

#import_attributes, #internal_attributes, #keyword_value_hash, #reject_data_attributes

Methods included from Shared::AlternateValues

#all_values_for, #alternate_valued?

Methods included from Housekeeping::Timestamps

#data_breakdown_for_chartkick_recent

Methods included from Housekeeping::Users

#set_created_by_id, #set_updated_by_id

Methods inherited from ApplicationRecord

transaction_with_retry

Instance Attribute Details

#abstractString

TODO:

Returns:

  • (String)


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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#address#String?

BibTeX standard field (optional for types: book, inbook, incollection, inproceedings, manual, mastersthesis, phdthesis, proceedings, techreport) Usually the address of the publisher or other type of institution. For major publishing houses, van Leunen recommends omitting the information entirely. For small publishers, on the other hand, you can help the reader by giving the complete address.

Returns:

  • (#String)

    the address

  • (nil)

    means the attribute is not stored in the database.



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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#annoteString?

BibTeX standard field (ignored by standard processors) An annotation. It is not used by the standard bibliography styles, but may be used by others that produce an annotated bibliography. (compare to a note which is any additional information which may be useful to the reader) In most cases these are personal annotations; TW will translate these into notes with a specified project so they will only be visible within the project where the note was made. <== Under debate with Matt.

Returns:

  • (String)

    the annotation

  • (nil)

    means the attribute is not stored in the database.



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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#authorString?

“Last name, FirstName MiddleName”. FirstName and MiddleName can be initials. Additional authors are joined with ‘ and `. All names before the comma are treated as a single last name.

The contents of ‘author` follow the following rules:

  • ‘author` (a) and `authors` (People) (b) can both be used to generate the author string

  • if a & !b then ‘author` = a verbatim (and therefor may not match the BibTeX format)

  • if !a & b then ‘author` = b, collected and rendered in BibTeX format

  • if a & b then ‘author` = b, collected and rendered in BibTeX format on each update. !! Updates to `author` directly will be overwritten !!

‘author` is automatically populated from `authors` if the latter is provided !! This is different behavious from TaxonName, where `verbatim_author` has priority over taxon_name_author (People) in rendering.

See also ‘cached_author_string`

Returns:

  • (String, nil)

    author names preferably rendered in BibTeX format,



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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#authors_to_createObject

Returns the value of attribute authors_to_create.



306
307
308
# File 'app/models/source/bibtex.rb', line 306

def authors_to_create
  @authors_to_create
end

#bibtex_typeString

Returns config/initializers/constants/_controlled_vocabularies/bibtex_constants one of VALID_BIBTEX_TYPES (.rb, keys there are symbols).

Returns:

  • (String)

    config/initializers/constants/_controlled_vocabularies/bibtex_constants one of VALID_BIBTEX_TYPES (.rb, keys there are symbols)



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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#booktitlenil

@return the title of the book BibTeX standard field (required for types: )(optional for types:) A TW required attribute (TW requires a value in one of the required attributes.) Title of a book, part of which is being cited. See the LaTEX book for how to type titles. For book entries, use the title field instead.

Returns:

  • (nil)

    means the attribute is not stored in the database.



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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#chapterString?

BibTeX standard field (required for types: )(optional for types:) A chapter (or section or whatever) number.

Returns:

  • (String)

    the chapter or section number.

  • (nil)

    means the attribute is not stored in the database.



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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end
TODO:

Returns:

  • (String)


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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#crossrefnil

@return the key of the cross referenced source BibTeX standard field (ignored by standard processors) The database key(key attribute) of the entry being cross referenced. This attribute is only set (and saved) during the import process, and is only relevant in a specific bibliography.

Returns:

  • (nil)

    means the attribute is not stored in the database.



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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#dayInteger

the actual publication month, NOT a BibTex standard field If day is present there must be a month and day must be valid for the month.

Returns:

  • (Integer)


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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#doiString

Returns:

  • (String)


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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#editionnil

@return the edition of the book BibTeX standard field (required for types: )(optional for types:) The edition of a book(for example, “Second”). This should be an ordinal, and should have the first letter capitalized, as shown here; the standard styles convert to lower case when necessary.

Returns:

  • (nil)

    means the attribute is not stored in the database.



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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#editorString

TODO:

Returns:

  • (String)


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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#howpublishednil

@return a description of how this source was published BibTeX standard field (required for types: )(optional for types:) How something unusual has been published. The first word should be capitalized.

Returns:

  • (nil)

    means the attribute is not stored in the database.



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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#institutionnil

@return the name of the institution publishing this source BibTeX standard field (required for types: )(optional for types:) The sponsoring institution of a technical report

Returns:

  • (nil)

    means the attribute is not stored in the database.



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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#isbnString

Returns:

  • (String)


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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#issnString

Returns:

  • (String)


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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#journalArray

Returns journal, nil or name.

Returns:

  • (Array)

    journal, nil or name



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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#keyString?

BibTeX standard field (may be used in a bibliography for alphabetizing & cross referencing) Used by bibtex-ruby gem method identifier as a default value when no other identifier is present. Used for alphabetizing, cross referencing, and creating a label when the “author” information is missing. This field should not be confused with the key that appears in the cite (BibTeX/LaTeX)command and at the beginning of the bibliography entry.

This attribute is only set (and saved) during the import process. It may be generated for output when a bibtex-ruby bibliography is created, but is unlikely to be save to the db. @return the key of this source

Returns:

  • (String)
  • (nil)

    means the attribute is not stored in the database.



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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#languageString

TODO:

BibTeX field for the name of the language used. This value is translated into the TW language_id.

Returns:

  • (String)


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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#language_idInteger

Returns language, from a controlled vocabulary.

Returns:

  • (Integer)

    language, from a controlled vocabulary



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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#monthnil

@return The three-letter lower-case abbreviation for the month in which this source was published. the actual publication month. a BibTeX standard field (required for types: ) (optional for types:) The month in which the work was published or, for an unpublished work, in which it was written. It should use the standard three-letter abbreviation, as described in Appendix B.1.3 of the LaTeX book. The three-letter lower-case abbreviations are available in BibTeX::MONTHS. If month is present there must be a year.

Returns:

  • (nil)

    means the attribute is not stored in the database.



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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#notenil

@return the BibTeX note associated with this source BibTeX standard field (required for types: unpublished)(optional for types:) Any additional information that can help the reader. The first word should be capitalized.

This attribute is used on import, but is otherwise ignored. Updates to this field are NOT transferred to the associated TW note and not added to any export. TW does NOT allow ‘|’ within a note. ('s are used to separate multiple TW notes associated with a single object on import)

Returns:

  • (nil)

    means the attribute is not stored in the database.



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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#numbernil

@return the number in a series, issue or technical report number associated with this source BibTeX standard field (required for types: )(optional for types:) The number of a journal, magazine, technical report, or of a work in a series. An issue of a journal or magazine is usually identified by its volume and number; the organization that issues a technical report usually gives it a number; and sometimes books are given numbers in a named series.

This attribute is equivalent to the Species File reference issue.

Returns:

  • (nil)

    means the attribute is not stored in the database.



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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#organizationnil

@return the organization associated with this source BibTeX standard field (required for types: )(optional for types:) The organization that sponsors a conference or that publishes a manual.

Returns:

  • (nil)

    means the attribute is not stored in the database.



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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#pagesString

BibTeX standard field (required for types: )(optional for types:) One or more page numbers or range of numbers, such as 42–111 or 7,41,73–97 or 43+ (the ‘+’ in this last example indicates pages following that don’t form a simple range). To make it easier to maintain Scribe- compatible databases, the standard styles convert a single dash (as in 7-33) to the double dash used in TeX to denote number ranges (as in 7–33).

Returns:

  • (String)


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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#publisherString

TODO:

Returns:

  • (String)


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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#schoolString

TODO:

Returns:

  • (String)


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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#seriesString

TODO:

Returns:

  • (String)


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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#stated_yearString

TODO:

Returns:

  • (String)


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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#titleString

A TW required attribute (TW requires a value in one of the required attributes.)

Returns:

  • (String)


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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#translatorString

bibtex-ruby gem supports translator, it’s not clear whether TW will or not. not yet implemented

Returns:

  • (String)


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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#typeString

TODO:

Returns:

  • (String)


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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#urlString

A TW required attribute for certain bibtex_types (TW requires a value in one of the required attributes.)

Returns:

  • (String)


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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#verbatimString

Non-Bibtex attribute that is cross-referenced.

Returns:

  • (String)


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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#verbatim_contentsString

TODO:

Returns:

  • (String)


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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#verbatim_keywordsString

TODO:

Returns:

  • (String)


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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#volumeString

TODO:

Returns:

  • (String)


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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#yearInteger

the actual publication year. a BibTeX standard field (required for types: ) (optional for types:) A TW required attribute (TW requires a value in one of the required attributes.) Year must be between 1000 and now + 2 years inclusive

Returns:

  • (Integer)


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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#year_suffixString

Returns like 1950a.

Returns:

  • (String)

    like 1950a



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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

Class Method Details

.batch_update(params) ⇒ Object



397
398
399
400
401
402
403
404
405
406
407
408
# File 'app/models/source/bibtex.rb', line 397

def self.batch_update(params)
  request = QueryBatchRequest.new(
    klass: 'Source',
    object_filter_params: params[:source_query],
    object_params: params[:source],
    async_cutoff: params[:async_cutoff] || 50,
    cap: 50,
    preview: params[:preview],
  )

  query_batch_update(request)
end

.bibtex_author_to_person(bibtex_author) ⇒ Person, Boolean

Returns new person, or false.

Parameters:

  • bibtex_author (BibTeX::Name)

Returns:

  • (Person, Boolean)

    new person, or false



412
413
414
415
416
417
418
419
420
421
# File 'app/models/source/bibtex.rb', line 412

def self.bibtex_author_to_person(bibtex_author)
  return false if bibtex_author.class != BibTeX::Name
  p = Person.new(
    first_name: bibtex_author.first,
    prefix: bibtex_author.prefix,
    last_name: bibtex_author.last,
    suffix: bibtex_author.suffix)
  p.namecase_names
  p
end

.new_from_bibtex(bibtex_entry = nil) ⇒ Source::Bibtex.new

Instantiates a Source::Bibtex instance from a BibTeX::Entry Note:

* note conversion is handled in note setter.
* identifiers are handled in associated setter.
* !! Unrecognized attributes are added as import attributes.

Usage:

a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
b = Source::Bibtex.new_from_bibtex(a)

TODO: Annote to project specific note? TODO: Serial with alternate_value on name .count = 1 assign .first

Parameters:

  • bibtex_entry (BibTex::Entry) (defaults to: nil)

    the BibTex::Entry to convert

Returns:



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
# File 'app/models/source/bibtex.rb', line 456

def self.new_from_bibtex(bibtex_entry = nil)
  return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
  s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

  import_attributes = []

  bibtex_entry.fields.each do |key, value|
    next if key == :serial # Raises if it hits the belongs_to

    if key == :keywords
      s.verbatim_keywords = value
      next
    end

    v = value.to_s.strip

    if s.respond_to?(key.to_sym) && key != :type
      s.send("#{key}=", v)
    else
      import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
    end
  end

  s.data_attributes_attributes = import_attributes

  # See issn=() for code matching to existing serials that preceeds this logic
  if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
    a = {
      name: bibtex_entry.fields[:journal].to_s,
      publisher: bibtex_entry.fields[:publisher].to_s,
      identifiers_attributes: [ {
        identifier: bibtex_entry.fields[:issn].to_s,
        type: 'Identifier::Global::Issn'
      } ]
    }

    s.serial_attributes = a
  end
  s
end

.new_from_bibtex_text(text = nil) ⇒ Source::Bibtex.new

errors are lost if save/valid? is called again on the object.

Returns:

  • (Source::Bibtex.new)

    Adds errors if parse error exists. Note these



426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
# File 'app/models/source/bibtex.rb', line 426

def self.new_from_bibtex_text(text = nil)
  source = Source::Bibtex.new
  begin
    a = BibTeX::Bibliography.parse(text, filter: :latex).first
    if a.class.name == 'BibTeX::Error'
      source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
      return source
    else
      return new_from_bibtex(a)
    end
  rescue BibTeX::ParseError => e
    source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
    return source
  end
end

Instance Method Details

#authority_name(reload = true) ⇒ String?

Returns last names formatted as displayed in nomenclatural authority (iczn), prioritizes normalized People before BibTeX ‘author` !! This is NOT a legal BibTeX format !!.

Returns:

  • (String, nil)

    last names formatted as displayed in nomenclatural authority (iczn), prioritizes normalized People before BibTeX ‘author` !! This is NOT a legal BibTeX format !!



859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
# File 'app/models/source/bibtex.rb', line 859

def authority_name(reload = true)
  reload ? authors.reload : authors.load

  if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
    if author.blank?
      return nil
    else
      b = to_bibtex
      ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
      return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
    end
  else # use normalized records
    # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
    return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
  end
end

#bibtex_bibliographyBibTex::Bibliography

Returns initialized with this Source as an entry.

Returns:

  • (BibTex::Bibliography)

    initialized with this Source as an entry



831
832
833
# File 'app/models/source/bibtex.rb', line 831

def bibtex_bibliography
   Vendor::BibtexRuby.bibliography([self])
end

#cached_nomenclature_dateDate || Time

Returns <sigh> An memoizer, getter for cached_nomenclature_date, computes if not .persisted?.

Returns:

  • (Date || Time)

    <sigh> An memoizer, getter for cached_nomenclature_date, computes if not .persisted?



733
734
735
736
737
738
739
# File 'app/models/source/bibtex.rb', line 733

def cached_nomenclature_date
  if !persisted?
    nomenclature_date
  else
    read_attribute(:cached_nomenclature_date)
  end
end

#cached_string(format = 'text') ⇒ String

String must be length > 0

Parameters:

  • format (String) (defaults to: 'text')

Returns:

  • (String)

    a full representation, using bibtex



850
851
852
853
# File 'app/models/source/bibtex.rb', line 850

def cached_string(format = 'text')
  return nil unless (format == 'text') || (format == 'html')
  str = render_with_style(DEFAULT_CSL_STYLE, format)
end

#check_has_fieldIgnored (protected)

must have at least one of the required fields (TW_REQUIRED_FIELDS)

Returns:

  • (Ignored)


956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
# File 'app/models/source/bibtex.rb', line 956

def check_has_field
  valid = false
  TW_REQUIRED_FIELDS.each do |i|
    if self[i].present?
      valid = true
      break
    end
  end

  # TODO This test for auth doesn't work with a new record.

  if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
    valid = true
  end

  if !valid
    errors.add(
      :base,
      'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
    )
  end
end

#create_authorsIgnored

Returns:

  • (Ignored)


918
919
920
921
922
923
924
925
926
927
928
929
930
# File 'app/models/source/bibtex.rb', line 918

def create_authors
  begin
    Person.transaction do
      authors_to_create.each do |shs|
        p = Person.create!(shs)
        author_roles.build(person: p)
      end
    end

  rescue ActiveRecord::RecordInvalid
    errors.add(:base, 'invalid author parameters')
  end
end

TODO: Not used

Modified from build, the issues with polymorphic has_many and build are more than we want to tackle right now.

Returns:

  • (Array, Boolean)

    of names, or false



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
# File 'app/models/source/bibtex.rb', line 525

def create_related_people_and_roles
  return false if !self.valid? ||
    self.new_record? ||
    (self.author.blank? && self.editor.blank?) ||
    self.roles.count > 0

  bibtex = to_bibtex
  ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

  begin
    Role.transaction do
      if bibtex.authors
        bibtex.authors.each do |a|
          p = Source::Bibtex.bibtex_author_to_person(a)
          p.save!
          SourceAuthor.create!(role_object: self, person: p)
        end
      end

      if bibtex.editors
        bibtex.editors.each do |a|
          p = Source::Bibtex.bibtex_author_to_person(a)
          p.save!
          SourceEditor.create!(role_object: self, person: p)
        end
      end
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  true
end

#get_authorString?

Returns priority is Person, string !! Not the cached value !!.

Returns:

  • (String, nil)

    priority is Person, string !! Not the cached value !!



820
821
822
823
824
825
826
827
# File 'app/models/source/bibtex.rb', line 820

def get_author
  a = authors.load
  if a.any?
    get_bibtex_names('author')
  else
    (author.presence)
  end
end

#get_bibtex_names(role_type) ⇒ String

Returns The BibTeX version of the name strings created from People BibTeX format is ‘lastname, firstname and lastname, firstname and lastname, firstname’ This only references People, i.e. ‘authors` and `editors`. !! Do not adapt to reference the BibTeX attributes `author` or `editor`.

Parameters:

  • type (String)

    either ‘author` or `editor`

Returns:

  • (String)

    The BibTeX version of the name strings created from People BibTeX format is ‘lastname, firstname and lastname, firstname and lastname, firstname’ This only references People, i.e. ‘authors` and `editors`. !! Do not adapt to reference the BibTeX attributes `author` or `editor`



912
913
914
915
# File 'app/models/source/bibtex.rb', line 912

def get_bibtex_names(role_type)
  # so, we can not reload here
  send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
end

#get_cachedObject



898
899
900
901
902
903
904
# File 'app/models/source/bibtex.rb', line 898

def get_cached
  if errors.empty?
    c = cached_string('html') # preserves TaxonWorks convention of <i>
    return c
  end
  nil
end

#has_authors?Boolean

is there a bibtex author or author roles?

Returns:

  • (Boolean)


697
698
699
700
701
702
# File 'app/models/source/bibtex.rb', line 697

def has_authors?
  return true if author.present?
  return false if new_record?
  # self exists in the db
  authors.count > 0 ? true : false
end

#has_editors?Boolean

Returns:

  • (Boolean)


705
706
707
708
709
710
711
# File 'app/models/source/bibtex.rb', line 705

def has_editors?
  return true if editor
  # editor attribute is empty
  return false if new_record? # WHY!?
  # self exists in the db
  editors.count > 0 ? true : false
end

#has_some_year?Boolean

Returns:

  • (Boolean)


720
721
722
723
# File 'app/models/source/bibtex.rb', line 720

def has_some_year? # is there a year or stated year?
  return false if year.blank? && stated_year.blank?
  true
end

#has_writer?Boolean

Returns true contains either an author or editor.

Returns:

  • (Boolean)

    true contains either an author or editor



715
716
717
# File 'app/models/source/bibtex.rb', line 715

def has_writer?
  (has_authors? || has_editors?) ? true : false
end

#identifier_string_of_type(type_value) ⇒ Identifier

Returns the identifier of this type, relies on Identifier to enforce has_one for Global identifiers !! behaviour for Identifier::Local types may be unexpected.

Parameters:

  • type_value (String)

Returns:

  • (Identifier)

    the identifier of this type, relies on Identifier to enforce has_one for Global identifiers !! behaviour for Identifier::Local types may be unexpected



684
685
686
687
688
689
690
691
# File 'app/models/source/bibtex.rb', line 684

def identifier_string_of_type(type_value)
  # Also handle in memory
  identifiers.each do |i|
    return i.identifier if i.type == type_value
  end
  nil
  # identifiers.where(type: type_value).first&.identifier
end

#italics_are_pairedObject (protected)



946
947
948
949
950
# File 'app/models/source/bibtex.rb', line 946

def italics_are_paired
  l = title.scan('<i>')&.count
  r = title.scan('</i>')&.count
  errors.add(:title, 'italic markup is not paired') unless l == r
end

#nomenclature_yearInteger

Returns The effective year of publication as per nomenclatural rules.

Returns:

  • (Integer)

    The effective year of publication as per nomenclatural rules



727
728
729
# File 'app/models/source/bibtex.rb', line 727

def nomenclature_year
  cached_nomenclature_date&.year
end

#render_with_style(style = 'vancouver', format = 'text', normalize_names = true) ⇒ String

Returns this source, rendered in the provided CSL style, as text.

Parameters:

  • style (String) (defaults to: 'vancouver')
  • format (String) (defaults to: 'text')

Returns:

  • (String)

    this source, rendered in the provided CSL style, as text



839
840
841
842
843
844
# File 'app/models/source/bibtex.rb', line 839

def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
  s = ::Vendor::BibtexRuby.get_style(style)
  cp = CiteProc::Processor.new(style: s, format:)
  cp.import( [to_citeproc(normalize_names)] )
  cp.render(:bibliography, id: cp.items.keys.first).first.strip
end

#serial_id=(value) ⇒ Fixnum?

Note:

not yet implemented!

Non-Bibtex attribute that is cross-referenced.

Returns:

  • (Fixnum)

    the unique identifier of the serial record in the Serial? table.

  • (nil)

    means the record does not exist in the database.



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
# File 'app/models/source/bibtex.rb', line 302

class Source::Bibtex < Source

  DEFAULT_CSL_STYLE = 'taxonworks'.freeze

  attr_accessor :authors_to_create

  include Shared::QueryBatchUpdate
  include Shared::OriginRelationship
  include Source::Bibtex::SoftValidationExtensions::Instance
  extend Source::Bibtex::SoftValidationExtensions::Klass

  is_origin_for 'Source::Bibtex', 'Source::Verbatim'
  originates_from 'Source::Bibtex', 'Source::Verbatim'

  GRAPH_ENTRY_POINTS = [:origin_relationships].freeze

  # Used in soft validation
  BIBTEX_REQUIRED_FIELDS = {
    article: [:author, :title, :journal, :year],
    book: [:author, :editor, :title, :publisher, :year],
    booklet: [:title],
    conference: [:author, :title, :booktitle, :year],
    inbook: [:author, :editor, :title, :chapter, :pages, :publisher, :year],
    incollection: [:author, :title, :booktitle, :publisher, :year],
    inproceedings:  [:author, :title, :booktitle, :year],
    manual: [:title],
    mastersthesis: [:author, :title, :school, :year],
    misc: [],
    phdthesis: [:author, :title, :school, :year],
    proceedings: [:title, :year],
    techreport: [:author,:title,:institution, :year],
    unpublished: [:author, :title, :note]
  }.freeze

  # TW required fields (must have one of these fields filled in)
  # either year or stated_year is acceptable
  TW_REQUIRED_FIELDS = [:author, :editor, :booktitle, :title, :url, :journal, :year, :stated_year].freeze

  IGNORE_SIMILAR = [:verbatim, :cached, :cached_author_string, :cached_nomenclature_date].freeze
  IGNORE_IDENTICAL = IGNORE_SIMILAR.dup.freeze

  belongs_to :serial, inverse_of: :sources

  # Handle conflict with BibTex language field.
  belongs_to :source_language, class_name: 'Language', foreign_key: :language_id, inverse_of: :sources

  has_many :author_roles, class_name: 'SourceAuthor', as: :role_object, dependent: :destroy, inverse_of: :role_object #  , -> { order('roles.position ASC') }
  has_many :authors, -> { order('roles.position ASC') }, through: :author_roles, source: :person, inverse_of: :authored_sources

  has_many :editor_roles, class_name: 'SourceEditor', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :editors, -> { order('roles.position ASC') }, through: :editor_roles, source: :person, inverse_of: :edited_sources

  has_many :source_roles, class_name: 'SourceSource', as: :role_object, dependent: :destroy, inverse_of: :role_object #, -> { order('roles.position ASC') }
  has_many :people, -> { order('roles.position ASC') }, through: :source_roles, source: :person, inverse_of: :human_sources

  accepts_nested_attributes_for :authors, :editors, :author_roles, :editor_roles, :serial, allow_destroy: true

  before_validation :create_authors, if: -> { !authors_to_create.nil? }

  before_validation :check_has_field

  validates_inclusion_of :bibtex_type,
    in: ::VALID_BIBTEX_TYPES,
    message: '"%{value}" is not a valid source type'

  validates_presence_of :year,
    if: -> { month.present? || stated_year.present? },
    message: 'is required when month or stated_year is provided'

  validates :year, date_year: {
    min_year: 1000, max_year: Time.now.year + 2,
    message: 'must be an integer greater than 999 and no more than 2 years in the future'}

  validates_presence_of :month,
    unless: -> { day.blank? },
    message: 'is required when day is provided'

  validates_inclusion_of :month,
    in: ::VALID_BIBTEX_MONTHS,
    allow_blank: true,
    message: ' month'

  validates :day, date_day: {year_sym: :year, month_sym: :month},
            unless: -> { year.blank? || month.blank? }

  validates :url, format: {
    with: URI::regexp(%w(http https ftp)),
    message: '[%{value}] is not a valid URL'}, allow_blank: true

  validate :italics_are_paired, unless: -> { title.blank? }
  validate :validate_year_suffix, unless: -> { self.no_year_suffix_validation || (self.type != 'Source::Bibtex') }

  # includes nil last, exclude it explicitly with another condition if need be
  scope :order_by_nomenclature_date, -> { order(:cached_nomenclature_date) }

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      klass: 'Source',
      object_filter_params: params[:source_query],
      object_params: params[:source],
      async_cutoff: params[:async_cutoff] || 50,
      cap: 50,
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  # @param [BibTeX::Name] bibtex_author
  # @return [Person, Boolean] new person, or false
  def self.bibtex_author_to_person(bibtex_author)
    return false if bibtex_author.class != BibTeX::Name
    p = Person.new(
      first_name: bibtex_author.first,
      prefix: bibtex_author.prefix,
      last_name: bibtex_author.last,
      suffix: bibtex_author.suffix)
    p.namecase_names
    p
  end

  # @return [Source::Bibtex.new]
  #   Adds errors if parse error exists. Note these
  # errors are lost if save/valid? is called again on the object.
  def self.new_from_bibtex_text(text = nil)
    source = Source::Bibtex.new
    begin
      a = BibTeX::Bibliography.parse(text, filter: :latex).first
      if a.class.name == 'BibTeX::Error'
        source.errors.add(:base, 'Unable to parse BibTeX entry. Possible error at end of: ' + a.content)
        return source
      else
        return new_from_bibtex(a)
      end
    rescue BibTeX::ParseError => e
      source.errors.add(:base, 'Unable to parse BibTeX entry: ' + e.to_s)
      return source
    end
  end

  # Instantiates a Source::Bibtex instance from a BibTeX::Entry
  # Note:
  #   * note conversion is handled in note setter.
  #   * identifiers are handled in associated setter.
  #   * !! Unrecognized attributes are added as import attributes.
  #
  # Usage:
  #   a = BibTeX::Entry.new(bibtex_type: 'book', title: 'Foos of Bar America', author: 'Smith, James', year: 1921)
  #   b = Source::Bibtex.new_from_bibtex(a)
  #
  # @param [BibTex::Entry] bibtex_entry the BibTex::Entry to convert
  # @return [Source::Bibtex.new] a new instance
  # TODO: Annote to project specific note?
  # TODO: Serial with alternate_value on name .count = 1 assign .first
  def self.new_from_bibtex(bibtex_entry = nil)
    return false if !bibtex_entry.kind_of?(::BibTeX::Entry)
    s = Source::Bibtex.new(bibtex_type: bibtex_entry.type.to_s)

    import_attributes = []

    bibtex_entry.fields.each do |key, value|
      next if key == :serial # Raises if it hits the belongs_to

      if key == :keywords
        s.verbatim_keywords = value
        next
      end

      v = value.to_s.strip

      if s.respond_to?(key.to_sym) && key != :type
        s.send("#{key}=", v)
      else
        import_attributes.push({import_predicate: key, value: v, type: 'ImportAttribute'})
      end
    end

    s.data_attributes_attributes = import_attributes

    # See issn=() for code matching to existing serials that preceeds this logic
    if s.serial_id.blank? && bibtex_entry.fields[:journal].to_s.present? && bibtex_entry.fields[:issn].to_s.present?
      a = {
        name: bibtex_entry.fields[:journal].to_s,
        publisher: bibtex_entry.fields[:publisher].to_s,
        identifiers_attributes: [ {
          identifier: bibtex_entry.fields[:issn].to_s,
          type: 'Identifier::Global::Issn'
        } ]
      }

      s.serial_attributes = a
    end
    s
  end

  # @return [Array] journal, nil or name
  def journal
    [read_attribute(:journal), (serial.blank? ? nil : serial.name)].compact.first
  end

  # @return [String]
  def verbatim_journal
    read_attribute(:journal)
  end

  # @return [Boolean]
  #   whether the BibTeX::Entry representation of this source is valid
  def valid_bibtex?
    to_bibtex.valid?
  end

  # @return [String] A string that represents the year with suffix as seen in a BibTeX bibliography.
  #   returns "" if neither :year or :year_suffix are set.
  def year_with_suffix
    [year, year_suffix].compact.join
  end

  # TODO: Not used
  #
  # Modified from build, the issues with polymorphic has_many and build
  # are more than we want to tackle right now.
  #
  # @return [Array, Boolean] of names, or false
  def create_related_people_and_roles
    return false if !self.valid? ||
      self.new_record? ||
      (self.author.blank? && self.editor.blank?) ||
      self.roles.count > 0

    bibtex = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(bibtex)

    begin
      Role.transaction do
        if bibtex.authors
          bibtex.authors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceAuthor.create!(role_object: self, person: p)
          end
        end

        if bibtex.editors
          bibtex.editors.each do |a|
            p = Source::Bibtex.bibtex_author_to_person(a)
            p.save!
            SourceEditor.create!(role_object: self, person: p)
          end
        end
      end
    rescue ActiveRecord::RecordInvalid
      raise
    end
    true
  end

  #region getters & setters

  # @param [String, Integer] value
  # @return [Integer] value of year
  def year=(value)
    if value.class == String
      value =~ /\A(\d\d\d\d)([a-zA-Z]*)\z/
      write_attribute(:year, $1.to_i) if $1
      write_attribute(:year_suffix, $2) if $2
      write_attribute(:year, value) if self.year.blank?
    else
      write_attribute(:year, value)
    end
  end

  # @param [String] value
  # @return [String]
  def month=(value)
    v = Utilities::Dates::SHORT_MONTH_FILTER[value]
    v = v.to_s if !v.nil?
    write_attribute(:month, v)
  end

  # Used only on import from BibTeX records
  # @param [String] value
  # @return [String]
  def note=(value)
    write_attribute(:note, value)
    if self.note.present? && self.new_record?
      if value.include?('||')
        a = value.split(/||/)
        a.each do |n|
          self.notes.build({text: n + ' [Created on import from BibTeX.]'})
        end
      else
        self.notes.build({text: value + ' [Created on import from BibTeX.]'})
      end
    end
  end

  # @param [String] value
  # @return [String]
  def isbn=(value)
    write_attribute(:isbn, value)
    if value.present?
      if tw_isbn = self.identifiers.where(type: 'Identifier::Global::Isbn').first
        if tw_isbn.identifier != value
          tw_isbn.destroy!
          self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Isbn', identifier: value)
      end
    end
  end

  # @return [String]
  def isbn
    identifier_string_of_type('Identifier::Global::Isbn')
  end

  # @param [String] value
  # @return [String]
  def doi=(value)
    write_attribute(:doi, value)
    if value.present?
      if tw_doi = self.identifiers.where(type: 'Identifier::Global::Doi').first
        if tw_doi.identifier != value
          tw_doi.destroy!
          self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
        end
      else
        self.identifiers.build(type: 'Identifier::Global::Doi', identifier: value)
      end
    end
  end

  # @return [String]
  def doi
    identifier_string_of_type('Identifier::Global::Doi')
  end

  # @param [String] value
  # @return [String]
  def issn=(value)
    # Only add ISSN if it is reasonable to assume its repeated
    # It is likely that most ISSN belong on Serials
    unless %w{article inbook phdthesis inproceedings}.include?(bibtex_type)
      write_attribute(:issn, value)
      if value.present?
        tw_issn = self.identifiers.where(type: 'Identifier::Global::Issn').first
        unless tw_issn.nil? || tw_issn.identifier != value
          tw_issn.destroy
        end
        self.identifiers.build(type: 'Identifier::Global::Issn', identifier: value)
      end
    else
      # Do some work to assign a Serial if possible
      # Check for Journal by ISSN
      s = Serial.where(name: journal).first
      i = Identifier::Global::Issn.where(identifier: value, identifier_object_type: 'Serial').first

      # Found an Existing Serial identically named with an assigned Identical ISSN
      if !s.nil? && (s == i&.identifier_object)
        write_attribute(:serial_id, s.id)
      elsif i && s.nil? # Found a Serial with an Identifier, but not identically named, assign it anyway
        write_attribute(:serial_id, i.identifier_object_id)
      end
    end
  end

  # @return [String]
  def issn
    identifier_string_of_type('Identifier::Global::Issn')
  end

  # turn bibtex URL field into a Ruby URI object
  # @return [URI]
  def url_as_uri
    URI(self.url) if self.url.present?
  end

  # @param [String] type_value
  # @return [Identifier]
  #   the identifier of this type, relies on Identifier to enforce has_one for Global identifiers
  #   !! behaviour for Identifier::Local types may be unexpected
  def identifier_string_of_type(type_value)
    # Also handle in memory
    identifiers.each do |i|
      return i.identifier if i.type == type_value
    end
    nil
    # identifiers.where(type: type_value).first&.identifier
  end

 #endregion getters & setters

  # @return [Boolean]
  # is there a bibtex author or author roles?
  def has_authors?
    return true if author.present?
    return false if new_record?
    # self exists in the db
    authors.count > 0 ? true : false
  end

  # @return [Boolean]
  def has_editors?
    return true if editor
    # editor attribute is empty
    return false if new_record? # WHY!?
    # self exists in the db
    editors.count > 0 ? true : false
  end

  # @return [Boolean]
  #  true contains either an author or editor
  def has_writer?
    (has_authors? || has_editors?) ? true : false
  end

  # @return [Boolean]
  def has_some_year? # is there a year or stated year?
    return false if year.blank? && stated_year.blank?
    true
  end

  # @return [Integer]
  #  The effective year of publication as per nomenclatural rules
  def nomenclature_year
    cached_nomenclature_date&.year
  end

  # @return [Date || Time] <sigh>
  #  An memoizer, getter for cached_nomenclature_date, computes if not .persisted?
  def cached_nomenclature_date
    if !persisted?
      nomenclature_date
    else
      read_attribute(:cached_nomenclature_date)
    end
  end

  # rubocop:disable Metrics/MethodLength
  # @return [BibTeX::Entry, false]
  #   !! Entry equivalent to self, this should round-trip with no changes.
  def to_bibtex
    return false if bibtex_type.nil?
    b = BibTeX::Entry.new(bibtex_type:)

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present?
        b[f] = v
      end
    end

    b[:keywords] = verbatim_keywords if verbatim_keywords.present?
    b[:note] = concatenated_notes_string if concatenated_notes_string.present?

    unless serial.nil?
      b[:journal] = serial.name
      issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
      unless issns.empty?
        b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
      end
    end

    uris = identifiers.where(type: 'Identifier::Global::Uri')
    unless uris.empty?
      b[:url] = uris.first.identifier # TW only allows one URI per object
    end

    isbns = identifiers.where(type: 'Identifier::Global::Isbn')
    unless isbns.empty?
      b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
    end

    dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
    unless dois.empty?
      b[:doi] = dois.first.identifier # TW only allows one DOI per object
    end

    # Overiden by `author` and `editor` if present
    b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
    b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

    # TODO: use global_id or replace with UUID or DOI if available
    b.key = id unless new_record?
    b
  end

  # @return Hash
  #   a to_citeproc with values updated for literal
  #   handling via `{}` in TaxonWorks
  def to_citeproc(normalize_names = true)
    b = to_bibtex
    ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

    a = b.to_citeproc

    ::BIBTEX_FIELDS.each do |f|
      next if f == :bibtex_type
      v = send(f)
      if v.present? && (v.to_s =~ /\A{(.*)}\z/)
        a[f.to_s] = {literal: $1}
      end
    end

    a['year-suffix'] = year_suffix if year_suffix.present?
    a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
    a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
    a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
    a['note'] = note
    a.reject! { |k| k == 'note' } if note.blank?
    a
  end

  # @return [String, nil]
  #  priority is Person, string
  #  !! Not the cached value !!
  def get_author
    a = authors.load
    if a.any?
      get_bibtex_names('author')
    else
      (author.presence)
    end
  end

  # @return [BibTex::Bibliography]
  #   initialized with this Source as an entry
  def bibtex_bibliography
    Vendor::BibtexRuby.bibliography([self])
 end

  # @param [String] style
  # @param [String] format
  # @return [String]
  #   this source, rendered in the provided CSL style, as text
  def render_with_style(style = 'vancouver', format = 'text', normalize_names = true)
    s = ::Vendor::BibtexRuby.get_style(style)
    cp = CiteProc::Processor.new(style: s, format:)
    cp.import( [to_citeproc(normalize_names)] )
    cp.render(:bibliography, id: cp.items.keys.first).first.strip
  end

  # @param [String] format
  # @return [String]
  #   a full representation, using bibtex
  # String must be length > 0
  def cached_string(format = 'text')
    return nil unless (format == 'text') || (format == 'html')
    str = render_with_style(DEFAULT_CSL_STYLE, format)
  end

  # @return [String, nil]
  #   last names formatted as displayed in nomenclatural authority (iczn), prioritizes
  #   normalized People before BibTeX `author`
  #   !! This is NOT a legal BibTeX format  !!
  def authority_name(reload = true)
    reload ? authors.reload : authors.load

    if !authors.any? # no normalized people, use string, !! not .any? because of in-memory setting?!
      if author.blank?
        return nil
      else
        b = to_bibtex
        ::Vendor::BibtexRuby.namecase_bibtex_entry(b)
        return Utilities::Strings.authorship_sentence(b.author.tokens.collect{ |t| t.last })
      end
    else # use normalized records
      # return Utilities::Strings.authorship_sentence(authors.collect{ |a| a.full_last_name })
      return Utilities::Strings.authorship_sentence(authors.pluck(:last_name))
    end
  end

  # TODO: Replace with taxonworks.csl.  Move unsupported fields to
  #   wrappers in vue rendering.
  # Set cached values and copies active record relations into bibtex values.
  #
  # @return [Boolean]
  def set_cached
    if errors.empty?
      attributes_to_update = {}

      attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
      attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

      attributes_to_update.merge!(
        cached: get_cached,
        cached_nomenclature_date: nomenclature_date,
        cached_author_string: authority_name(false)
      )

      self.reload.update_columns(attributes_to_update)
    end
  end

  def get_cached
    if errors.empty?
      c = cached_string('html') # preserves TaxonWorks convention of <i>
      return c
    end
    nil
  end

  # @param [String] type either `author` or `editor`
  # @return [String]
  #   The BibTeX version of the name strings created from People
  #   BibTeX format is 'lastname, firstname and lastname, firstname and lastname, firstname'
  #   This only references People, i.e. `authors` and `editors`.
  #   !! Do not adapt to reference the BibTeX attributes `author` or `editor`
  def get_bibtex_names(role_type)
    # so, we can not reload here
    send("#{role_type}s").collect{ |a| a.bibtex_name}.join(' and ')
  end

  # @return [Ignored]
  def create_authors
    begin
      Person.transaction do
        authors_to_create.each do |shs|
          p = Person.create!(shs)
          author_roles.build(person: p)
        end
      end

    rescue ActiveRecord::RecordInvalid
      errors.add(:base, 'invalid author parameters')
    end
  end

  protected

  def validate_year_suffix
    a = get_author
    unless year_suffix.blank? || year.blank? || a.blank?
      if new_record?
        s = Source.where(author: a, year:, year_suffix:).first
      else
        s = Source.where(author: a, year:, year_suffix:).not_self(self).first
      end
      errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
    end
  end

  def italics_are_paired
    l = title.scan('<i>')&.count
    r = title.scan('</i>')&.count
    errors.add(:title, 'italic markup is not paired') unless l == r
  end

  #region hard validations

  # must have at least one of the required fields (TW_REQUIRED_FIELDS)
  # @return [Ignored]
  def check_has_field
    valid = false
    TW_REQUIRED_FIELDS.each do |i|
      if self[i].present?
        valid = true
        break
      end
    end

    # TODO This test for auth doesn't work with a new record.

    if (self.authors.count > 0 || self.editors.count > 0 || !self.serial.nil?)
      valid = true
    end

    if !valid
      errors.add(
        :base,
        'Missing core data. A TaxonWorks source must have one of the following: author, editor, booktitle, title, url, journal, year, or stated year'
      )
    end
  end

  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

    if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
        (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
        cached != get_cached ||
        cached_nomenclature_date != nomenclature_date ||
        cached_author_string.to_s != authority_name(false)
      is_cached = false
    end

    soft_validations.add(
      :base, 'Cached values should be updated',
      success_message: 'Cached values were updated',
      failure_message:  'Failed to update cached values') if !is_cached
  end
end

#set_cachedBoolean

TODO: Replace with taxonworks.csl. Move unsupported fields to

wrappers in vue rendering.

Set cached values and copies active record relations into bibtex values.

Returns:

  • (Boolean)


881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
# File 'app/models/source/bibtex.rb', line 881

def set_cached
  if errors.empty?
    attributes_to_update = {}

    attributes_to_update[:author] = get_bibtex_names('author') if authors.reload.size > 0
    attributes_to_update[:editor] = get_bibtex_names('editor') if editors.reload.size > 0

    attributes_to_update.merge!(
      cached: get_cached,
      cached_nomenclature_date: nomenclature_date,
      cached_author_string: authority_name(false)
    )

    self.reload.update_columns(attributes_to_update)
  end
end

#sv_cached_namesObject (protected)

this cannot be moved to soft_validation_extensions



979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
# File 'app/models/source/bibtex.rb', line 979

def sv_cached_names # this cannot be moved to soft_validation_extensions
  is_cached = true

  if (author.to_s != get_bibtex_names('author') && get_bibtex_names('author').present?) ||
      (editor.to_s != get_bibtex_names('editor') && get_bibtex_names('editor').present?) ||
      cached != get_cached ||
      cached_nomenclature_date != nomenclature_date ||
      cached_author_string.to_s != authority_name(false)
    is_cached = false
  end

  soft_validations.add(
    :base, 'Cached values should be updated',
    success_message: 'Cached values were updated',
    failure_message:  'Failed to update cached values') if !is_cached
end

#to_bibtexBibTeX::Entry, false

rubocop:disable Metrics/MethodLength

Returns:

  • (BibTeX::Entry, false)

    !! Entry equivalent to self, this should round-trip with no changes.



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
# File 'app/models/source/bibtex.rb', line 744

def to_bibtex
  return false if bibtex_type.nil?
  b = BibTeX::Entry.new(bibtex_type:)

  ::BIBTEX_FIELDS.each do |f|
    next if f == :bibtex_type
    v = send(f)
    if v.present?
      b[f] = v
    end
  end

  b[:keywords] = verbatim_keywords if verbatim_keywords.present?
  b[:note] = concatenated_notes_string if concatenated_notes_string.present?

  unless serial.nil?
    b[:journal] = serial.name
    issns = serial.identifiers.where(type: 'Identifier::Global::Issn')
    unless issns.empty?
      b[:issn] = issns.first.identifier # assuming the serial has only 1 ISSN
    end
  end

  uris = identifiers.where(type: 'Identifier::Global::Uri')
  unless uris.empty?
    b[:url] = uris.first.identifier # TW only allows one URI per object
  end

  isbns = identifiers.where(type: 'Identifier::Global::Isbn')
  unless isbns.empty?
    b[:isbn] = isbns.first.identifier # TW only allows one ISBN per object
  end

  dois = identifiers.where(type: 'Identifier::Global::Doi') #.of_type(:isbn)
  unless dois.empty?
    b[:doi] = dois.first.identifier # TW only allows one DOI per object
  end

  # Overiden by `author` and `editor` if present
  b.author = get_bibtex_names('author') if author_roles.load.any? # unless (!authors.load.any? && author.blank?)
  b.editor = get_bibtex_names('editor') if editor_roles.load.any? # unless (!editors.load.any? && editor.blank?)

  # TODO: use global_id or replace with UUID or DOI if available
  b.key = id unless new_record?
  b
end

#to_citeproc(normalize_names = true) ⇒ Object

Returns Hash a to_citeproc with values updated for literal handling via ‘{}` in TaxonWorks.

Returns:

  • Hash a to_citeproc with values updated for literal handling via ‘{}` in TaxonWorks



794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
# File 'app/models/source/bibtex.rb', line 794

def to_citeproc(normalize_names = true)
  b = to_bibtex
  ::Vendor::BibtexRuby.namecase_bibtex_entry(b) if normalize_names

  a = b.to_citeproc

  ::BIBTEX_FIELDS.each do |f|
    next if f == :bibtex_type
    v = send(f)
    if v.present? && (v.to_s =~ /\A{(.*)}\z/)
      a[f.to_s] = {literal: $1}
    end
  end

  a['year-suffix'] = year_suffix if year_suffix.present?
  a['original-date'] = {'date-parts' => [[ stated_year ]]} if stated_year.present? && stated_year.to_s != year.to_s
  a['language'] = Language.find(language_id).english_name.to_s unless language_id.nil?
  a['translated-title'] = alternate_values.where(type: 'AlternateValue::Translation', alternate_value_object_attribute: 'title').pluck(:value).first
  a['note'] = note
  a.reject! { |k| k == 'note' } if note.blank?
  a
end

#url_as_uriURI

turn bibtex URL field into a Ruby URI object

Returns:

  • (URI)


676
677
678
# File 'app/models/source/bibtex.rb', line 676

def url_as_uri
  URI(self.url) if self.url.present?
end

#valid_bibtex?Boolean

Returns whether the BibTeX::Entry representation of this source is valid.

Returns:

  • (Boolean)

    whether the BibTeX::Entry representation of this source is valid



509
510
511
# File 'app/models/source/bibtex.rb', line 509

def valid_bibtex?
  to_bibtex.valid?
end

#validate_year_suffixObject (protected)



934
935
936
937
938
939
940
941
942
943
944
# File 'app/models/source/bibtex.rb', line 934

def validate_year_suffix
  a = get_author
  unless year_suffix.blank? || year.blank? || a.blank?
    if new_record?
      s = Source.where(author: a, year:, year_suffix:).first
    else
      s = Source.where(author: a, year:, year_suffix:).not_self(self).first
    end
    errors.add(:year_suffix, " '#{year_suffix}' is already used for #{a} #{year}") unless s.nil?
  end
end

#verbatim_journalString

Returns:

  • (String)


503
504
505
# File 'app/models/source/bibtex.rb', line 503

def verbatim_journal
  read_attribute(:journal)
end

#year_with_suffixString

A string that represents the year with suffix as seen in a BibTeX bibliography. returns “” if neither :year or :year_suffix are set.

Returns:

  • (String)

    A string that represents the year with suffix as seen in a BibTeX bibliography. returns “” if neither :year or :year_suffix are set.



515
516
517
# File 'app/models/source/bibtex.rb', line 515

def year_with_suffix
  [year, year_suffix].compact.join
end