From 89201233ede7f19e96fa316f38a3232c246b1e64 Mon Sep 17 00:00:00 2001 From: Luis M Rodriguez-R Date: Fri, 4 Sep 2026 13:59:27 +0200 Subject: [PATCH 1/6] Initial implementation of DOI-less publications Modeling and validations for DOI-less publications. Also, rough initial UI (only for curators) to register DOI-less publications --- app/controllers/publications_controller.rb | 74 ++++++++++------- app/models/publication.rb | 39 +++++++-- app/models/register/status.rb | 5 +- app/views/contacts/new.html.erb | 11 ++- app/views/contacts/show.html.erb | 11 ++- app/views/publications/new.html.erb | 29 ++++++- app/views/publications/show.html.erb | 16 ++-- app/views/registers/cite.xml.erb | 1 + app/views/registers/notify.html.erb | 9 +- .../publications_controller_test.rb | 43 ++++++++++ test/fixtures/publications.yml | 11 +++ test/models/publication_test.rb | 82 ++++++++++++++++++- 12 files changed, 265 insertions(+), 66 deletions(-) diff --git a/app/controllers/publications_controller.rb b/app/controllers/publications_controller.rb index 0ea9a911..58d4e648 100644 --- a/app/controllers/publications_controller.rb +++ b/app/controllers/publications_controller.rb @@ -51,41 +51,44 @@ def edit # POST /publications def create - @publication = Publication.by_doi(params['publication']['doi']) - if @publication.new_record? - render('new') + if params['publication']['doi'].blank? && current_user.try(:curator?) + @publication = Publication.new(publication_manual_params) + return render('new') unless @publication.save else - if params[:link_name] && params[:link_name][:id] - @name = Name.find(params[:link_name][:id]) - par = { publication: @publication, name: @name } - pn = PublicationName.find_or_create_by(par) + @publication = Publication.by_doi(params['publication']['doi']) + return render('new') if @publication.new_record? + end + + if params[:link_name] && params[:link_name][:id] + @name = Name.find(params[:link_name][:id]) + par = { publication: @publication, name: @name } + pn = PublicationName.find_or_create_by(par) - case params[:link_name][:as] - when 'propose' - @name.update(proposed_in: @publication) - flash[:notice] = 'Effective publication registered' - when 'not_valid_proposal' - pn.update(not_valid_proposal: true) - flash[:notice] = - 'Original (not valid) publication registered' - when 'corrig' - redirect_to( - corrigendum_in_name_url(@name, publication_id: @publication.id) - ) - return - when 'assign' - @name.update(assigned_in: @publication) - flash[:notice] = 'Taxonomic assignment publication registered' - when 'emend' - pn.update(emends: true) - flash[:notice] = 'Emending publication registered' - else - flash[:notice] = 'Publication registered' - end - redirect_to(@name) + case params[:link_name][:as] + when 'propose' + @name.update(proposed_in: @publication) + flash[:notice] = 'Effective publication registered' + when 'not_valid_proposal' + pn.update(not_valid_proposal: true) + flash[:notice] = + 'Original (not valid) publication registered' + when 'corrig' + redirect_to( + corrigendum_in_name_url(@name, publication_id: @publication.id) + ) + return + when 'assign' + @name.update(assigned_in: @publication) + flash[:notice] = 'Taxonomic assignment publication registered' + when 'emend' + pn.update(emends: true) + flash[:notice] = 'Emending publication registered' else - redirect_to(@publication) + flash[:notice] = 'Publication registered' end + redirect_to(@name) + else + redirect_to(@publication) end end @@ -128,6 +131,15 @@ def set_publication params[:doi] ? Publication.find_by(doi: params[:doi]) : nil end + # Fields a curator may hand-enter to register a publication without a + # DOI (e.g. an older paper never issued one). Deliberately excludes + # crossref_json/datacite_json, which only ever hold verbatim external-API + # payloads and have no place in a manually-entered record. + def publication_manual_params + params.require(:publication) + .permit(*%i[title journal journal_loc journal_date doi url pub_type abstract]) + end + # Never trust parameters from the scary internet, only allow the white list through. def publication_params params.require(:publication) diff --git a/app/models/publication.rb b/app/models/publication.rb index 5615730a..5b701c1c 100644 --- a/app/models/publication.rb +++ b/app/models/publication.rb @@ -3,6 +3,16 @@ class Publication < ApplicationRecord default_scope { order(journal_date: :desc) } + # An empty string isn't the same as NULL for the DB's unique index on doi + # (two blank-string dois would collide; two NULLs don't), so normalize + # blank input before it ever reaches a validation or a save. + before_validation { self.doi = nil if doi.blank? } + + validates(:title, presence: true) + validates(:journal_date, presence: true) + validates(:pub_type, presence: true) + validates(:doi, uniqueness: true, allow_nil: true) + has_many( :publication_authors, -> { order('publication_authors.id ASC') }, dependent: :destroy @@ -34,8 +44,17 @@ class Publication < ApplicationRecord class << self def by_autocomplete(query) - doi = query&.sub(/: .*/, '') - doi.present? ? by_doi(doi) : nil + key = query&.sub(/: .*/, '') + return nil unless key.present? + + key.start_with?('id:') ? + by_id(key.delete_prefix('id:')) : + by_doi(key) + end + + def by_id(id) + Publication.find_by(id: id) || + Publication.new.tap { |i| i.errors.add(:doi, 'publication not found') } end def by_doi(doi, force_update = false) @@ -248,17 +267,20 @@ def journal_raw def long_citation(format = :text) case format.to_sym when :html + doi_html = doi.present? ? + %(DOI: #{doi}) : '' <<~HTML.html_safe - #{authors_et_al(format)} (#{journal_date.year}). #{title_html}. + #{authors_et_al(format)} (#{journal_date.year}). #{title_html}. #{journal_html}. - DOI: #{doi} + #{doi_html} HTML when :wikispecies "#{authors_array(format).join(', ')}. #{journal_date.year}: " \ - "#{title.gsub(/[\n\r\s]+/, ' ')}. #{journal_raw}. {{Doi|#{doi}}}" + "#{title.gsub(/[\n\r\s]+/, ' ')}. #{journal_raw}" \ + "#{doi.present? ? ". {{Doi|#{doi}}}" : '.'}" else "#{authors_et_al(format)} (#{journal_date.year}). " \ - "#{title}. #{journal_raw}. DOI: #{doi}" + "#{title}. #{journal_raw}#{doi.present? ? ". DOI: #{doi}" : '.'}" end end @@ -283,11 +305,12 @@ def prepub? end def link - "https://doi.org/#{doi}" + "https://doi.org/#{doi}" if doi.present? end def doi_title(html = true) - html ? "#{doi}: #{title_html}" : "#{doi}: #{title}" + key = doi.presence || "id:#{id}" + html ? "#{key}: #{title_html}" : "#{key}: #{title}" end def include_term?(term) diff --git a/app/models/register/status.rb b/app/models/register/status.rb index 9f425406..28ac2640 100644 --- a/app/models/register/status.rb +++ b/app/models/register/status.rb @@ -124,9 +124,10 @@ def endorse(user) # # user: The user notifying the Registry (the current user) # params: The notification parameters - # doi: DOI of the effective publication + # doi: DOI of the effective publication, or a doi_title-style "key: title" + # round-trip string identifying a DOI-less publication def notify(user, params, doi) - publication = Publication.by_doi(doi) + publication = Publication.by_autocomplete(doi) || Publication.by_doi(doi) params[:publication] = publication if publication.new_record? diff --git a/app/views/contacts/new.html.erb b/app/views/contacts/new.html.erb index 002838c5..0fb674ce 100644 --- a/app/views/contacts/new.html.erb +++ b/app/views/contacts/new.html.erb @@ -25,10 +25,13 @@
<%= link_to(@publication) do %> <%= fa_icon('arrow-circle-right') %> <%= @publication.citation %> - <% end %> / - <%= link_to(@publication.link, target: '_blank') do %> - DOI: <%= @publication.doi %> - <%= fa_icon('external-link-alt') %> + <% end %> + <% if @publication.doi.present? %> + / + <%= link_to(@publication.link, target: '_blank') do %> + DOI: <%= @publication.doi %> + <%= fa_icon('external-link-alt') %> + <% end %> <% end %>
diff --git a/app/views/contacts/show.html.erb b/app/views/contacts/show.html.erb index 9b5be610..1f4eda72 100644 --- a/app/views/contacts/show.html.erb +++ b/app/views/contacts/show.html.erb @@ -9,10 +9,13 @@
<%= link_to(@publication) do %> <%= fa_icon('arrow-circle-right') %> <%= @publication.citation %> - <% end %>
- <%= link_to(@publication.link, target: '_blank') do %> - DOI: <%= @publication.doi %> - <%= fa_icon('external-link-alt') %> + <% end %> + <% if @publication.doi.present? %> +
+ <%= link_to(@publication.link, target: '_blank') do %> + DOI: <%= @publication.doi %> + <%= fa_icon('external-link-alt') %> + <% end %> <% end %>
diff --git a/app/views/publications/new.html.erb b/app/views/publications/new.html.erb index e247ff8e..cb3fadf7 100644 --- a/app/views/publications/new.html.erb +++ b/app/views/publications/new.html.erb @@ -1,14 +1,35 @@

New Publication

- Only publications with assigned Digital Object Identifier - (<%= link_to('DOI', 'https://doi.org/', target: '_blank') %>) - can be registered + <% if current_curator? %> + Publications are normally registered by their Digital Object Identifier + (<%= link_to('DOI', 'https://doi.org/', target: '_blank') %>). As a + curator, you may leave the DOI blank and enter the publication's + details manually instead (e.g. for older papers that never received one). + <% else %> + Only publications with assigned Digital Object Identifier + (<%= link_to('DOI', 'https://doi.org/', target: '_blank') %>) + can be registered + <% end %>

<%= simple_form_for(@publication, hrml: { method: :post }) do |f| %> <%= f.input(:doi, - required: true, autofocus: true, label: 'DOI of publication') %> + required: !current_curator?, autofocus: true, + label: 'DOI of publication') %> + <% if current_curator? %> +

+ Leave DOI blank to enter the publication's details manually: +

+ <%= f.input(:title, required: true) %> + <%= f.input(:journal) %> + <%= f.input(:journal_loc, label: 'Journal location (volume/issue)') %> + <%= f.input(:journal_date, required: true) %> + <%= f.input(:pub_type, required: true, label: 'Publication type', + hint: 'e.g. journal-article, book, posted-content') %> + <%= f.input(:url) %> + <%= f.input(:abstract, as: :text) %> + <% end %> <% if @name %> <%= hidden_field(:link_name, :id, value: @name.id) %>

diff --git a/app/views/publications/show.html.erb b/app/views/publications/show.html.erb index 2bee92b9..0c28a032 100644 --- a/app/views/publications/show.html.erb +++ b/app/views/publications/show.html.erb @@ -24,13 +24,15 @@

<%= fa_icon('calendar-day') %> Publication date
<%= @publication.journal_date %>
-
<%= fa_icon('directions') %> DOI
-
- <%= link_to(@publication.url, target: '_blank') do %> - <%= @publication.doi %>  - <%= fa_icon('external-link-alt') %> - <% end %> -
+ <% if @publication.doi.present? %> +
<%= fa_icon('directions') %> DOI
+
+ <%= link_to(@publication.url, target: '_blank') do %> + <%= @publication.doi %>  + <%= fa_icon('external-link-alt') %> + <% end %> +
+ <% end %> <%= render(partial: 'curator') %>
diff --git a/app/views/registers/cite.xml.erb b/app/views/registers/cite.xml.erb index bdf3a71e..4a4b88c6 100644 --- a/app/views/registers/cite.xml.erb +++ b/app/views/registers/cite.xml.erb @@ -83,6 +83,7 @@ <%# ========= Citations ========= %> <% @register.citations.each do |publication| %> + <% next unless publication.doi.present? %> <%= publication.doi %> <% end %> diff --git a/app/views/registers/notify.html.erb b/app/views/registers/notify.html.erb index dbada0ba..d5343d63 100644 --- a/app/views/registers/notify.html.erb +++ b/app/views/registers/notify.html.erb @@ -37,7 +37,10 @@

Effective Publication

<%= f.input( :doi, as: :fake, label: 'DOI', required: true, - input_html: { value: @register.publication.try(:doi) }, + input_html: { + value: @register.publication.try(:doi) || + @register.publication.try(:doi_title, false) + }, hint: 'Digital Object Identifier (DOI) of the effective publication' ) %> <% unless @publications.empty? %> @@ -45,11 +48,11 @@ <% @publications.each do |publication| %>
  • <%= link_to(publication.short_citation, publication) %>: - <%= publication.doi %> + <%= publication.doi || 'no DOI' %> <%= content_tag( 'span', role: 'button', cursor: 'pointer', class: 'badge badge-pill badge-primary', - onclick: "$('#doi').val('#{publication.doi}'); false" + onclick: "$('#doi').val('#{publication.doi || publication.doi_title(false)}'); false" ) do %> <%= fa_icon('check-circle') %> Select <% end %> diff --git a/test/controllers/publications_controller_test.rb b/test/controllers/publications_controller_test.rb index ea66ebbf..26dbfdc5 100644 --- a/test/controllers/publications_controller_test.rb +++ b/test/controllers/publications_controller_test.rb @@ -1,8 +1,51 @@ require 'test_helper' class PublicationsControllerTest < ActionDispatch::IntegrationTest + include Devise::Test::IntegrationHelpers + setup do @publication = publications(:one) end + test 'curator can register a publication without a doi' do + sign_in(users(:curator)) + + assert_difference('Publication.count', 1) do + post( + publications_url, + params: { + publication: { + doi: '', title: 'A Manually Entered Paper', + journal: 'Journal of Examples', + journal_date: Date.new(1978, 6, 1), pub_type: 'journal-article' + } + } + ) + end + + publication = Publication.find_by(title: 'A Manually Entered Paper') + assert_not_nil publication + assert_nil publication.doi + assert_redirected_to publication + end + + test 'non-curator cannot register a publication without a doi' do + sign_in(users(:contributor)) + + assert_no_difference('Publication.count') do + post( + publications_url, + params: { + publication: { + doi: '', title: 'A Manually Entered Paper', + journal: 'Journal of Examples', + journal_date: Date.new(1978, 6, 1), pub_type: 'journal-article' + } + } + ) + end + + assert_response :success + assert_includes @response.body, 'New Publication' + end end diff --git a/test/fixtures/publications.yml b/test/fixtures/publications.yml index 6c3a3345..278e717c 100644 --- a/test/fixtures/publications.yml +++ b/test/fixtures/publications.yml @@ -8,6 +8,7 @@ one: doi: MyString url: MyString crossref_json: MyString + pub_type: journal-article two: title: MyString @@ -17,3 +18,13 @@ two: doi: MyOtherString url: MyString crossref_json: MyString + pub_type: journal-article + +no_doi: + title: A Classic Pre-Digital Paper + journal: MyString + journal_loc: MyString + journal_date: 2019-04-11 + doi: + url: + pub_type: journal-article diff --git a/test/models/publication_test.rb b/test/models/publication_test.rb index 12defd6c..fc37aacc 100644 --- a/test/models/publication_test.rb +++ b/test/models/publication_test.rb @@ -1,7 +1,83 @@ require 'test_helper' class PublicationTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + test 'requires a title' do + p = Publication.new( + journal_date: Date.new(2020, 1, 1), pub_type: 'journal-article' + ) + assert_not p.valid? + assert_includes p.errors[:title], "can't be blank" + end + + test 'requires a journal_date' do + p = Publication.new(title: 'A Paper', pub_type: 'journal-article') + assert_not p.valid? + assert_includes p.errors[:journal_date], "can't be blank" + end + + test 'requires a pub_type' do + p = Publication.new(title: 'A Paper', journal_date: Date.new(2020, 1, 1)) + assert_not p.valid? + assert_includes p.errors[:pub_type], "can't be blank" + end + + test 'allows multiple publications without a doi' do + a = Publication.new( + title: 'A Paper', journal_date: Date.new(2020, 1, 1), + pub_type: 'journal-article' + ) + b = Publication.new( + title: 'Another Paper', journal_date: Date.new(2021, 1, 1), + pub_type: 'journal-article' + ) + assert a.valid? + assert b.valid? + end + + test 'rejects a duplicate doi' do + dup = Publication.new( + title: 'Duplicate', journal_date: Date.new(2020, 1, 1), + pub_type: 'journal-article', doi: publications(:one).doi + ) + assert_not dup.valid? + assert_includes dup.errors[:doi], 'has already been taken' + end + + test 'link is nil without a doi' do + assert_nil publications(:no_doi).link + assert_equal( + 'https://doi.org/%s' % publications(:one).doi, publications(:one).link + ) + end + + test 'doi_title falls back to an id-based key without a doi' do + p = publications(:no_doi) + assert_equal("id:#{p.id}: #{p.title}", p.doi_title(false)) + end + + test 'by_autocomplete resolves the id-based key back to the publication' do + p = publications(:no_doi) + found = Publication.by_autocomplete(p.doi_title(false)) + assert_equal p, found + end + + test 'by_autocomplete still resolves a plain doi' do + p = publications(:one) + found = Publication.by_autocomplete(p.doi_title(false)) + assert_equal p, found + end + + test 'long_citation omits the DOI clause when doi is blank' do + p = publications(:no_doi) + assert_not_includes p.long_citation(:text), 'DOI' + assert_not_includes p.long_citation(:html), 'DOI' + assert_not_includes p.long_citation(:wikispecies), 'Doi' + end + + test 'long_citation includes the DOI clause when doi is present' do + p = publications(:one) + assert_includes p.long_citation(:text), "DOI: #{p.doi}" + assert_includes p.long_citation(:html), "DOI: #{p.doi}" + assert_includes p.long_citation(:wikispecies), "{{Doi|#{p.doi}}}" + end end From 94003d499ba7a694eee513786ef8ac4cce31961e Mon Sep 17 00:00:00 2001 From: Luis M Rodriguez-R Date: Fri, 4 Sep 2026 14:49:52 +0200 Subject: [PATCH 2/6] Complete UI to register DOI-less publications --- app/controllers/publications_controller.rb | 3 + app/models/publication.rb | 33 +++++++++ app/views/publications/_form.html.erb | 20 +++++- app/views/publications/new.html.erb | 68 +++++++++++++++++-- app/views/publications/show.html.erb | 6 ++ config/routes.rb | 11 ++- test/controllers/journals_controller_test.rb | 29 ++++++++ .../publications_controller_test.rb | 57 ++++++++++++++++ test/models/publication_test.rb | 30 ++++++++ 9 files changed, 250 insertions(+), 7 deletions(-) create mode 100644 test/controllers/journals_controller_test.rb diff --git a/app/controllers/publications_controller.rb b/app/controllers/publications_controller.rb index 58d4e648..9020297d 100644 --- a/app/controllers/publications_controller.rb +++ b/app/controllers/publications_controller.rb @@ -54,6 +54,9 @@ def create if params['publication']['doi'].blank? && current_user.try(:curator?) @publication = Publication.new(publication_manual_params) return render('new') unless @publication.save + @publication.add_authors( + params[:authors_given].to_a.zip(params[:authors_family].to_a) + ) else @publication = Publication.by_doi(params['publication']['doi']) return render('new') if @publication.new_record? diff --git a/app/models/publication.rb b/app/models/publication.rb index 5b701c1c..917e7509 100644 --- a/app/models/publication.rb +++ b/app/models/publication.rb @@ -219,6 +219,39 @@ def journals where.not(journal: ['', nil]).select(:journal).reorder(:journal).distinct end + # Curated subset of the Crossref/DataCite `type` vocabulary offered in + # the manual-entry dropdown. Values match what by_serrano_work/ + # by_datacite_work already store, so existing display code (e.g. + # #citation, #journal_html) keeps working unchanged either way. + def pub_types + { + 'journal-article' => 'Journal article', + 'book' => 'Book', + 'book-chapter' => 'Book chapter', + 'proceedings-article' => 'Conference proceedings', + 'report' => 'Report', + 'other' => 'Other' + } + end + + end + + ## + # Attach authors given as an array of [given, family] pairs, in + # authorship order. Mirrors the author-creation loop in + # +by_uniform_hash_work+, so the same dedup/standardization rules + # (Author.find_or_create) apply regardless of whether the author list + # came from Crossref/DataCite or was hand-entered. + def add_authors(pairs) + pairs.each_with_index do |(given, family), i| + next if family.blank? + author = Author.find_or_create(given, family) + next if authors.include?(author) + PublicationAuthor.new( + publication_id: id, author_id: author.id, + sequence: i.zero? ? 'first' : 'additional' + ).save + end end def authors_et_al(format = :text) diff --git a/app/views/publications/_form.html.erb b/app/views/publications/_form.html.erb index e1c5f0c7..48b560d5 100644 --- a/app/views/publications/_form.html.erb +++ b/app/views/publications/_form.html.erb @@ -28,7 +28,10 @@
    <%= form.label :journal_date %> - <%= form.date_select :journal_date, id: :publication_journal_date %> + <%= form.date_select( + :journal_date, { start_year: 1900, end_year: Date.current.year + 1 }, + id: :publication_journal_date + ) %>
    @@ -43,7 +46,20 @@
    <%= form.label :pub_type %> - <%= form.text_field :pub_type, id: :publication_pub_type %> + <% + pub_type_options = Publication.pub_types.to_a.map(&:reverse) + if publication.pub_type.present? && + !Publication.pub_types.key?(publication.pub_type) + # Preserve an existing value outside the curated list (e.g. a + # Crossref/DataCite type like "dataset" or "posted-content") as + # its own option, instead of silently losing it on the next save. + pub_type_options << [publication.pub_type, publication.pub_type] + end + %> + <%= form.select( + :pub_type, pub_type_options, { include_blank: true }, + id: :publication_pub_type + ) %>
    diff --git a/app/views/publications/new.html.erb b/app/views/publications/new.html.erb index cb3fadf7..d3c31a5f 100644 --- a/app/views/publications/new.html.erb +++ b/app/views/publications/new.html.erb @@ -22,13 +22,73 @@ Leave DOI blank to enter the publication's details manually:

    <%= f.input(:title, required: true) %> - <%= f.input(:journal) %> + <%= f.input(:journal, + hint: 'Use the full journal name, not an abbreviation') %> <%= f.input(:journal_loc, label: 'Journal location (volume/issue)') %> - <%= f.input(:journal_date, required: true) %> - <%= f.input(:pub_type, required: true, label: 'Publication type', - hint: 'e.g. journal-article, book, posted-content') %> + <%= f.input(:journal_date, required: true, + start_year: 1900, end_year: Date.current.year + 1, + hint: 'If the exact date is unknown, use January 1st') %> + <%= f.input(:pub_type, + collection: Publication.pub_types.to_a.map(&:reverse), + include_blank: 'Select a publication type', + required: true, label: 'Publication type') %> <%= f.input(:url) %> <%= f.input(:abstract, as: :text) %> + + <% + # Repopulate from the previous submission if this is a re-render after + # a validation error, so authors survive it the same way every other + # field already does via @publication's own attributes. + author_pairs = (params[:authors_given] || []).zip(params[:authors_family] || []) + author_pairs = [[nil, nil]] if author_pairs.empty? + %> +
    + +
    + <% author_pairs.each do |given, family| %> +
    +
    + <%= text_field_tag( + 'authors_given[]', given, class: 'form-control', + placeholder: 'Given name (e.g. initials)' + ) %> +
    +
    + <%= text_field_tag( + 'authors_family[]', family, class: 'form-control', + placeholder: 'Family name' + ) %> +
    +
    + +
    +
    + <% end %> +
    + +
    + + <% end %> <% if @name %> <%= hidden_field(:link_name, :id, value: @name.id) %> diff --git a/app/views/publications/show.html.erb b/app/views/publications/show.html.erb index 0c28a032..4e82df6c 100644 --- a/app/views/publications/show.html.erb +++ b/app/views/publications/show.html.erb @@ -33,6 +33,12 @@ <% end %> <% end %> + <% if @publication.url.present? %> +
    <%= fa_icon('link') %> URL
    +
    + <%= link_to(@publication.url, @publication.url, target: '_blank') %> +
    + <% end %> <%= render(partial: 'curator') %>
    diff --git a/config/routes.rb b/config/routes.rb index ab2cb581..08b135b1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -209,7 +209,16 @@ resources(:authors, concerns: :autocompletable) # Journals - resources(:journals, only: %i[index show], param: :journal) + # Journal names routinely contain periods (e.g. "Cryptogamie. Algologie"), + # which Rails' default segment matcher excludes to leave room for format + # detection (":journal.json"), so it silently truncates them instead. + # Constrain :journal to swallow everything but a real trailing ".json"/ + # ".html", so a literal period in the name is matched as part of :journal + # while an actual format suffix is still recognized. + resources( + :journals, only: %i[index show], param: :journal, + constraints: { journal: /[^\/]+?/, format: /json|html/ } + ) # Publications resources(:publications, concerns: :autocompletable) do diff --git a/test/controllers/journals_controller_test.rb b/test/controllers/journals_controller_test.rb new file mode 100644 index 00000000..bbf5f6c3 --- /dev/null +++ b/test/controllers/journals_controller_test.rb @@ -0,0 +1,29 @@ +require 'test_helper' + +class JournalsControllerTest < ActionDispatch::IntegrationTest + test 'show resolves a journal name with an embedded period, untruncated' do + get('/journals/' + ERB::Util.url_encode('Cryptogamie. Algologie')) + assert_response :success + assert_equal 'Cryptogamie. Algologie', @request.params[:journal] + assert_nil @request.params[:format] + end + + test 'show resolves a journal name with a trailing period' do + get('/journals/' + ERB::Util.url_encode('Can. J. Fish. Aquat. Sci. Bull.')) + assert_response :success + assert_equal 'Can. J. Fish. Aquat. Sci. Bull.', @request.params[:journal] + end + + test 'show still honors an explicit .json format on a plain journal name' do + get('/journals/' + ERB::Util.url_encode('Nature') + '.json') + assert_response :success + assert_equal 'Nature', @request.params[:journal] + assert_equal 'json', @request.params[:format] + end + + test 'index still honors an explicit .json format' do + get('/journals.json') + assert_response :success + assert_equal 'json', @request.params[:format] + end +end diff --git a/test/controllers/publications_controller_test.rb b/test/controllers/publications_controller_test.rb index 26dbfdc5..39149415 100644 --- a/test/controllers/publications_controller_test.rb +++ b/test/controllers/publications_controller_test.rb @@ -7,6 +7,18 @@ class PublicationsControllerTest < ActionDispatch::IntegrationTest @publication = publications(:one) end + test 'curator sees the manual-entry form with an 1900-based year range' do + sign_in(users(:curator)) + + get(new_publication_url) + + assert_response :success + assert_includes @response.body, 'Authors (given name, family name)' + assert_select( + 'select#publication_journal_date_1i option[value="1900"]' + ) + end + test 'curator can register a publication without a doi' do sign_in(users(:curator)) @@ -29,6 +41,51 @@ class PublicationsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to publication end + test 'curator can register a publication with manually entered authors' do + sign_in(users(:curator)) + + post( + publications_url, + params: { + publication: { + doi: '', title: 'A Multi-Author Paper', + journal: 'Journal of Examples', + journal_date: Date.new(1978, 6, 1), pub_type: 'journal-article' + }, + authors_given: ['J.', 'A.'], + authors_family: ['Doe', 'Smith'] + } + ) + + publication = Publication.find_by(title: 'A Multi-Author Paper') + assert_not_nil publication + assert_equal ['Doe', 'Smith'], publication.authors.pluck(:family) + end + + test 'authors survive a re-render after a validation error' do + sign_in(users(:curator)) + + assert_no_difference('Publication.count') do + post( + publications_url, + params: { + publication: { + doi: '', title: '', journal: 'Journal of Examples', + journal_date: Date.new(1978, 6, 1), pub_type: 'journal-article' + }, + authors_given: ['J.', 'A.'], + authors_family: ['Doe', 'Smith'] + } + ) + end + + assert_response :success + assert_select('input[name="authors_given[]"][value="J."]') + assert_select('input[name="authors_family[]"][value="Doe"]') + assert_select('input[name="authors_given[]"][value="A."]') + assert_select('input[name="authors_family[]"][value="Smith"]') + end + test 'non-curator cannot register a publication without a doi' do sign_in(users(:contributor)) diff --git a/test/models/publication_test.rb b/test/models/publication_test.rb index fc37aacc..ebf38c20 100644 --- a/test/models/publication_test.rb +++ b/test/models/publication_test.rb @@ -80,4 +80,34 @@ class PublicationTest < ActiveSupport::TestCase assert_includes p.long_citation(:html), "DOI: #{p.doi}" assert_includes p.long_citation(:wikispecies), "{{Doi|#{p.doi}}}" end + + test 'add_authors creates authors in order with a first/additional sequence' do + p = publications(:no_doi) + p.add_authors([['J.', 'Doe'], ['A.', 'Smith']]) + + assert_equal ['Doe', 'Smith'], p.reload.authors.pluck(:family) + sequences = p.publication_authors.order(:id).pluck(:sequence) + assert_equal %w[first additional], sequences + end + + test 'add_authors skips rows with a blank family name' do + p = publications(:no_doi) + assert_no_difference('Author.count') do + p.add_authors([['', ''], [nil, '']]) + end + assert_empty p.reload.authors + end + + test 'add_authors does not duplicate an already-linked author' do + p = publications(:no_doi) + p.add_authors([['J.', 'Doe']]) + assert_no_difference('PublicationAuthor.count') do + p.add_authors([['J.', 'Doe']]) + end + end + + test 'pub_types is used for the manual-entry dropdown' do + assert_includes Publication.pub_types.keys, 'journal-article' + assert_equal 'Journal article', Publication.pub_types['journal-article'] + end end From 42087e1de6e1fb799ee80a05ff8dcb9cc6316bc4 Mon Sep 17 00:00:00 2001 From: Luis M Rodriguez-R Date: Fri, 4 Sep 2026 14:53:15 +0200 Subject: [PATCH 3/6] Link publication to name when defining paratypes --- app/models/name_paratype.rb | 13 +++++++++++++ test/models/name_paratype_test.rb | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/app/models/name_paratype.rb b/app/models/name_paratype.rb index 7fa19003..4c0e61c8 100644 --- a/app/models/name_paratype.rb +++ b/app/models/name_paratype.rb @@ -10,4 +10,17 @@ class NameParatype < ApplicationRecord # Per the SeqCode paratype amendment (DOI:10.1093/ismeco/ycaf238), only # isolated strains can serve as paratypes validates(:nomenclatural_type_type, inclusion: { in: %w[Strain] }) + + after_create(:link_publication_to_name) + + private + + # A paratype's registering publication should also be recognized as one + # of the name's own publications, same as propose/assign/emend/corrig + # already ensure (see PublicationsController#create). Uses find_or_create + # so this holds regardless of which code path creates the NameParatype, + # and is a no-op if the publication is already linked. + def link_publication_to_name + PublicationName.find_or_create_by(publication: publication, name: name) + end end diff --git a/test/models/name_paratype_test.rb b/test/models/name_paratype_test.rb index 4825d255..c9be9984 100644 --- a/test/models/name_paratype_test.rb +++ b/test/models/name_paratype_test.rb @@ -38,6 +38,30 @@ class NameParatypeTest < ActiveSupport::TestCase assert(name_paratype.valid?) end + test 'links the paratype publication to the name if not already linked' do + name = names(:bacillus_subtilis) + publication = publications(:one) + assert_not_includes(name.publications, publication) + + NameParatype.create!( + name: name, nomenclatural_type: strains(:one), publication: publication + ) + + assert_includes(name.reload.publications, publication) + end + + test 'does not duplicate an already-existing publication link' do + name = names(:bacillus_subtilis) + publication = publications(:one) + PublicationName.create!(publication: publication, name: name) + + assert_no_difference('PublicationName.count') do + NameParatype.create!( + name: name, nomenclatural_type: strains(:one), publication: publication + ) + end + end + test 'only allows strains as paratypes' do name_paratype = NameParatype.new( name: names(:bacillus_subtilis), nomenclatural_type: genomes(:one), From c526040cbc3dfaf84a5465e75ddc01424d8e6ed6 Mon Sep 17 00:00:00 2001 From: Luis M Rodriguez-R Date: Fri, 4 Sep 2026 15:05:30 +0200 Subject: [PATCH 4/6] Cleaner UI Hide the bulky publication form by default, and allow for autocomplete when possible --- app/controllers/publications_controller.rb | 9 +- app/views/publications/new.html.erb | 125 +++++++++++------- .../publications_controller_test.rb | 61 +++++++++ 3 files changed, 145 insertions(+), 50 deletions(-) diff --git a/app/controllers/publications_controller.rb b/app/controllers/publications_controller.rb index 9020297d..d98d2e44 100644 --- a/app/controllers/publications_controller.rb +++ b/app/controllers/publications_controller.rb @@ -58,7 +58,14 @@ def create params[:authors_given].to_a.zip(params[:authors_family].to_a) ) else - @publication = Publication.by_doi(params['publication']['doi']) + doi = params['publication']['doi'] + # by_autocomplete resolves either a raw DOI (falling through to + # by_doi, so "type a fresh DOI to register it" keeps working + # unchanged) or a doi_title-style "key: title" string from selecting + # an existing publication in the autocomplete dropdown — including a + # DOI-less one via its "id:" key. Only falls back to by_doi + # directly for blank input, matching its pre-existing error message. + @publication = Publication.by_autocomplete(doi) || Publication.by_doi(doi) return render('new') if @publication.new_record? end diff --git a/app/views/publications/new.html.erb b/app/views/publications/new.html.erb index d3c31a5f..45cb566d 100644 --- a/app/views/publications/new.html.erb +++ b/app/views/publications/new.html.erb @@ -16,64 +16,91 @@ <%= simple_form_for(@publication, hrml: { method: :post }) do |f| %> <%= f.input(:doi, required: !current_curator?, autofocus: true, - label: 'DOI of publication') %> + label: 'DOI of publication', + hint: 'Enter a DOI to search or register a publication, ' \ + 'or search by title to find an existing one', + input_html: { + data: { behavior: 'autocomplete', autocomplete: 'publications' } + }) %> <% if current_curator? %> -

    - Leave DOI blank to enter the publication's details manually: -

    - <%= f.input(:title, required: true) %> - <%= f.input(:journal, - hint: 'Use the full journal name, not an abbreviation') %> - <%= f.input(:journal_loc, label: 'Journal location (volume/issue)') %> - <%= f.input(:journal_date, required: true, - start_year: 1900, end_year: Date.current.year + 1, - hint: 'If the exact date is unknown, use January 1st') %> - <%= f.input(:pub_type, - collection: Publication.pub_types.to_a.map(&:reverse), - include_blank: 'Select a publication type', - required: true, label: 'Publication type') %> - <%= f.input(:url) %> - <%= f.input(:abstract, as: :text) %> - <% - # Repopulate from the previous submission if this is a re-render after - # a validation error, so authors survive it the same way every other - # field already does via @publication's own attributes. - author_pairs = (params[:authors_given] || []).zip(params[:authors_family] || []) - author_pairs = [[nil, nil]] if author_pairs.empty? + # Mirrors PublicationsController#create's own branch condition, so a + # re-render after a failed manual submission (even one that failed + # specifically because title/journal_date/etc. were left blank) still + # expands the section instead of hiding the curator's own entry + # behind a collapsed toggle. + manual_entry_active = + params[:publication].present? && params[:publication][:doi].blank? %> -
    - -
    - <% author_pairs.each do |given, family| %> -
    -
    - <%= text_field_tag( - 'authors_given[]', given, class: 'form-control', - placeholder: 'Given name (e.g. initials)' - ) %> -
    -
    - <%= text_field_tag( - 'authors_family[]', family, class: 'form-control', - placeholder: 'Family name' - ) %> -
    -
    - +

    + <%= link_to('Register a publication without a DOI', '#', + id: 'toggle-manual-entry', + class: 'btn btn-outline-secondary btn-sm') %> +

    +
    +

    + Leave DOI blank above and fill in the publication's details manually: +

    + <%= f.input(:title, required: true) %> + <%= f.input(:journal, + hint: 'Use the full journal name, not an abbreviation') %> + <%= f.input(:journal_loc, label: 'Journal location (volume/issue)') %> + <%= f.input(:journal_date, required: true, + start_year: 1900, end_year: Date.current.year + 1, + hint: 'If the exact date is unknown, use January 1st') %> + <%= f.input(:pub_type, + collection: Publication.pub_types.to_a.map(&:reverse), + include_blank: 'Select a publication type', + required: true, label: 'Publication type') %> + <%= f.input(:url) %> + <%= f.input(:abstract, as: :text) %> + + <% + # Repopulate from the previous submission if this is a re-render + # after a validation error, so authors survive it the same way + # every other field already does via @publication's own attributes. + author_pairs = + (params[:authors_given] || []).zip(params[:authors_family] || []) + author_pairs = [[nil, nil]] if author_pairs.empty? + %> +
    + +
    + <% author_pairs.each do |given, family| %> +
    +
    + <%= text_field_tag( + 'authors_given[]', given, class: 'form-control', + placeholder: 'Given name (e.g. initials)' + ) %> +
    +
    + <%= text_field_tag( + 'authors_family[]', family, class: 'form-control', + placeholder: 'Family name' + ) %> +
    +
    + +
    -
    - <% end %> + <% end %> +
    +
    -