diff --git a/Gemfile b/Gemfile index 6cc3e289..8b4a2ef4 100644 --- a/Gemfile +++ b/Gemfile @@ -14,6 +14,7 @@ gem 'bootstrap', '~> 4.6.2' gem 'coffee-rails', '~> 5.0' gem 'csv' gem 'd3-rails' +gem 'diff-lcs', '~> 2.0' gem 'dartsass-sprockets' # Sass engine (required by bootstrap gem 'devise' gem 'dkim', '~> 1.1' diff --git a/Gemfile.lock b/Gemfile.lock index 213666a1..d151f2d4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -114,6 +114,7 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) + diff-lcs (2.0.0) dkim (1.1.0) erb (6.0.7) erubi (1.13.1) @@ -483,6 +484,7 @@ DEPENDENCIES d3-rails dartsass-sprockets devise + diff-lcs (~> 2.0) dkim (~> 1.1) font_awesome5_rails (~> 1.5) httparty (~> 0.23) diff --git a/app/assets/stylesheets/names.scss b/app/assets/stylesheets/names.scss index 6064a6a6..eb2029c6 100644 --- a/app/assets/stylesheets/names.scss +++ b/app/assets/stylesheets/names.scss @@ -16,3 +16,55 @@ } } +.name-history-table { + table-layout: fixed; + min-width: 42rem; + + thead th:first-child { + width: 12rem; + } + + thead th:nth-child(2) { + width: 9rem; + } + + thead th:nth-child(3) { + width: 10rem; + } + + .name-history-date { + color: #6c757d; + font-size: 0.875rem; + font-weight: normal; + white-space: nowrap; + } + + .name-history-author { + font-size: 0.875rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + .fa, .fas, .far { + opacity: 0.6; + } + } + + .name-history-version:nth-of-type(even) { + background-color: #f7f8fa; + } + + .name-history-version + .name-history-version { + border-top: 0.75rem solid var(--white); + } + + .name-history-record ~ .name-history-change th { + padding-left: 1.5rem; + } + + .name-history-before, + .name-history-after { + white-space: pre-wrap; + overflow-wrap: anywhere; + } +} diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 3e8787e4..1f0ba3c2 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -2,6 +2,7 @@ class ApplicationController < ActionController::Base include PageHelper before_action(:check_api!) + before_action { Current.user = current_user } before_action(:store_user_location!, if: :storable_location?) rescue_from ActionController::InvalidAuthenticityToken do |exception| diff --git a/app/controllers/names/histories_controller.rb b/app/controllers/names/histories_controller.rb new file mode 100644 index 00000000..f057618f --- /dev/null +++ b/app/controllers/names/histories_controller.rb @@ -0,0 +1,17 @@ +class Names::HistoriesController < ApplicationController + before_action(:set_name) + + # GET /names/1/history + def show + @history = Name::PublicHistory.new(@name) + @versions = @history.versions(page: params[:page]) + @crumbs = [['Names', names_path], [@name.abbr_name, @name], 'History'] + end + + private + + def set_name + @name = Name.find(params[:name_id]) + render('names/hidden') unless @name.can_view?(current_user, cookies[:reviewer_token]) + end +end diff --git a/app/controllers/placements_controller.rb b/app/controllers/placements_controller.rb index 5fd9a6aa..ea681f4e 100644 --- a/app/controllers/placements_controller.rb +++ b/app/controllers/placements_controller.rb @@ -150,7 +150,7 @@ def destroy Placement.transaction do # Only the preferred placement determines the name's classification. @name.update(parent: nil, assigned_in: nil) if @placement.preferred? - @placement.destroy + @placement.soft_delete! end redirect_to(@name, notice: 'Name placement was removed') end diff --git a/app/controllers/publication_names_controller.rb b/app/controllers/publication_names_controller.rb index 73808d7f..a34cd0e2 100644 --- a/app/controllers/publication_names_controller.rb +++ b/app/controllers/publication_names_controller.rb @@ -19,7 +19,7 @@ def destroy if name.assigned_in? publication name.update(assigned_in: nil) end - @publication_name.destroy + @publication_name.soft_delete! end redirect_to( diff --git a/app/helpers/names/histories_helper.rb b/app/helpers/names/histories_helper.rb new file mode 100644 index 00000000..8accd43d --- /dev/null +++ b/app/helpers/names/histories_helper.rb @@ -0,0 +1,124 @@ +module Names::HistoriesHelper + # Render a recorded [before, after] pair as two display values. + def history_values(changes, attribute:, record_type: 'Name') + # Some name attributes need domain-specific formatting; everything else, + # on any record type, is rendered generically from its model. + values = history_name_values(changes, attribute) if record_type == 'Name' + values ||= history_attribute_values(record_type.constantize, changes, attribute) + + # Diffs return arrays of parts; empty values show as a dash. + values.map { |value| safe_join(Array(value)).presence || '—' } + end + + # Rich text changes are shown as changes of the name itself. + def history_record_type(version) + version.record_type == 'ActionText::RichText' ? 'Name' : version.record_type + end + + def history_record_reference(record_class, id) + return unless id + + record = record_class.find_by(id: id) + if record.nil? + "#{record_class.name} ##{id} (deleted)" + elsif record.is_a?(GenericTypeMaterial) + # Generic type material has no dedicated page to link to. + record.display + else + link_to(record.display, record) + end + end + + # Label a link's changes with what it links to. + def history_record_heading(version) + return history_link_reference(version.record) if version.record + + # The link itself was deleted, e.g. along with its publication. + "#{version.record_type.constantize.model_name.human} (deleted)" + end + + # Publication links identify their publication. Placement headings stay neutral + # because a retained placement's current parent may differ from earlier edits. + def history_link_reference(record) + case record + when PublicationName + safe_join(['Publication: ', link_to(record.display, record.publication)]) + when Placement + 'Placement' + end + end + + def history_text_diff(before, after) + chunks = TextDiff.chunks(before, after) + + before_parts = chunks.filter_map do |unchanged, text, _| + next if text.empty? + + unchanged ? text : content_tag(:del, text, class: 'text-danger') + end + after_parts = chunks.filter_map do |unchanged, _, text| + next if text.empty? + + unchanged ? text : content_tag(:ins, text, class: 'text-success') + end + + [before_parts, after_parts] + end + + # Diff rich text HTML, rendering each side as formatted text with its changes + # marked. The HTML is sanitized as ActionText content is, plus ins and del. + def history_html_diff(before, after) + HtmlDiff.diff(before, after).map do |html| + sanitize( + html, + tags: ActionText::ContentHelper.allowed_tags + %w[ins del], + attributes: ActionText::ContentHelper.allowed_attributes + ) + end + end + + private + + # Name attributes that need domain-specific rendering, or nil for the rest. + def history_name_values(changes, attribute) + case attribute + when 'description', 'etymology_text' + history_html_diff(*changes) + when 'etymology' + history_text_diff(*changes.map { |attributes| Name.new(attributes).full_etymology }) + when 'corrigendum_kind' + changes.map { |kind| Name.corrigendum_kinds.dig(kind&.to_sym, :name) || kind } + when 'status' + changes.map { |code| Name.status_hash.dig(code, :name) || code } + end + end + + # Link foreign keys to their records, diff text, and show booleans as Yes/No. + # Ordinary associations are matched by their actual foreign key, which need + # not follow the association's name. Polymorphic references arrive grouped + # under the association's name as [type, id] pairs. + def history_attribute_values(model, changes, attribute) + association = model.reflect_on_all_associations(:belongs_to).find do |reflection| + attribute == (reflection.polymorphic? ? reflection.name.to_s : reflection.foreign_key) + end + if association&.polymorphic? + changes.map { |type, id| history_polymorphic_reference(type, id) } + elsif association + changes.map { |id| history_record_reference(association.klass, id) } + elsif changes.any? { |value| value.is_a?(String) } + history_text_diff(*changes) + else + changes.map { |value| { true => 'Yes', false => 'No' }.fetch(value, value) } + end + end + + def history_polymorphic_reference(type, id) + # Resolve only model classes, not any constant a recorded type string names. + record_class = type&.safe_constantize + if record_class.is_a?(Class) && record_class < ApplicationRecord + history_record_reference(record_class, id) + elsif id + "#{type} ##{id}" + end + end +end diff --git a/app/models/concerns/soft_deletable.rb b/app/models/concerns/soft_deletable.rb new file mode 100644 index 00000000..d7b9266c --- /dev/null +++ b/app/models/concerns/soft_deletable.rb @@ -0,0 +1,16 @@ +module SoftDeletable + extend ActiveSupport::Concern + + included do + default_scope { where(deleted_at: nil) } + scope :with_deleted, -> { unscope(where: :deleted_at) } + end + + def soft_delete! + update!(deleted_at: Time.current) + end + + def soft_deleted? + deleted_at.present? + end +end diff --git a/app/models/concerns/versioned.rb b/app/models/concerns/versioned.rb new file mode 100644 index 00000000..10315264 --- /dev/null +++ b/app/models/concerns/versioned.rb @@ -0,0 +1,86 @@ +module Versioned + extend ActiveSupport::Concern + + included do + has_many :versions, as: :record + class_attribute :versioned_attribute_sets, default: [] + + after_create :record_create_version + after_update :record_update_version + after_destroy :record_delete_version + end + + class_methods do + # Changing any member records before/after values for every member. + def versioned_together(*attributes) + self.versioned_attribute_sets = versioned_attribute_sets + [attributes.map(&:to_s)] + end + end + + private + + def versioned_attributes + attributes.transform_values { |value| versioned_value(value) } + end + + def versioned_saved_changes + saved_changes.transform_values { |values| values.map { |value| versioned_value(value) } } + end + + # A rich text's body is a Content object; record its HTML. + def versioned_value(value) + value.is_a?(ActionText::Content) ? value.to_html.presence : value + end + + def record_create_version + versions.create!(operation: 'create', user: Current.user, changeset: versioned_attributes.transform_values { |value| [nil, value] }) + end + + def record_update_version + changeset = versioned_changes + versions.create!(operation: 'update', user: Current.user, changeset: changeset) if changeset.any? + end + + def record_delete_version + # The owner is already destroyed, so create directly rather than through + # its association. Retain the final attributes and history. + Version.create!( + record: self, operation: 'delete', user: Current.user, + changeset: versioned_attributes.transform_values { |value| [value, nil] } + ) + end + + # A changeset maps each changed attribute to [before, after], e.g. + # { 'syllabication' => [nil, 'co.li'] } for E. coli. + # Create and delete versions change every attribute from or to nothing, + # retaining the full attributes: { 'rank' => [nil, 'species'] } on create and + # { 'rank' => ['species', nil] } on delete. + # Only update changesets omit timestamps. + def versioned_changes + changeset = versioned_saved_changes.except('created_at', 'updated_at') + + # A polymorphic ID is only meaningful together with its record type. + polymorphic_sets = self.class.reflect_on_all_associations(:belongs_to) + .select(&:polymorphic?) + .map { |association| [association.foreign_key, association.foreign_type] } + + changed_columns = changeset.keys + (self.class.versioned_attribute_sets + polymorphic_sets).each do |columns| + changeset.merge!(versioned_together_changes(columns, changed_columns)) + end + changeset + end + + # If any listed column changed, include each member with a value as an + # ordinary [before, after] pair, including unchanged columns such as language: + # { 'etymology_xx_lang' => ['N.L.', 'N.L.'], + # 'etymology_xx_description' => ['of the colon', 'from the colon'] }. + def versioned_together_changes(columns, changed_columns) + return {} if (changed_columns & columns).empty? + + columns.filter_map do |column| + values = [attribute_before_last_save(column), self[column]] + [column, values] unless values == [nil, nil] + end.to_h + end +end diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 00000000..761ee022 --- /dev/null +++ b/app/models/current.rb @@ -0,0 +1,4 @@ +class Current < ActiveSupport::CurrentAttributes + # The user whose request is being handled. Versions record it as their author. + attribute :user +end diff --git a/app/models/name.rb b/app/models/name.rb index cf5b4741..8e9c6760 100644 --- a/app/models/name.rb +++ b/app/models/name.rb @@ -1,6 +1,9 @@ class Name < ApplicationRecord has_many(:pseudonyms, dependent: :destroy) - has_many(:publication_names, dependent: :destroy) + has_many(:publication_names) + # Owner deletion also removes the relationships retained for history. + has_many(:all_publication_names, -> { with_deleted }, + class_name: 'PublicationName', dependent: :destroy, inverse_of: :name) has_many(:publications, through: :publication_names) has_many( :name_correspondences, -> { order(:created_at) }, dependent: :destroy @@ -26,16 +29,20 @@ class Name < ApplicationRecord has_many(:check_users, -> { distinct }, through: :checks, source: :user) has_many( :placements, -> { includes(:parent) }, - dependent: :destroy, inverse_of: :name + inverse_of: :name ) + has_many(:all_placements, -> { with_deleted }, + class_name: 'Placement', dependent: :destroy, inverse_of: :name) delegate( :incertae_sedis, :incertae_sedis?, :incertae_sedis_html, :incertae_sedis_text, to: :placement, allow_nil: true ) has_many( :child_placements, -> { includes(:name) }, - class_name: 'Placement', foreign_key: 'parent_id', dependent: :destroy + class_name: 'Placement', foreign_key: 'parent_id' ) + has_many(:all_child_placements, -> { with_deleted }, + class_name: 'Placement', foreign_key: 'parent_id', dependent: :destroy) has_many(:observe_names, dependent: :destroy) has_many(:observers, through: :observe_names, source: :user) has_many( @@ -161,6 +168,10 @@ class Name < ApplicationRecord include Name::Wiki include Name::FuzzySearch include TypeMaterial + include Versioned + + ETYMOLOGY_COLUMNS = column_names.grep(/\Aetymology_/).freeze + versioned_together(:name, *ETYMOLOGY_COLUMNS) attr_accessor :only_display attr_accessor :nomenclatural_type_entry diff --git a/app/models/name/public_history.rb b/app/models/name/public_history.rb new file mode 100644 index 00000000..0c7a5074 --- /dev/null +++ b/app/models/name/public_history.rb @@ -0,0 +1,153 @@ +class Name::PublicHistory + # Only these recorded attributes appear in the public history. + NAME_KEYS = %w[ + name rank syllabication status priority_date authority + nomenclatural_status taxonomic_status proposal_kind corrigendum_from corrigendum_kind + nomenclatural_type_type nomenclatural_type_id + proposed_in_id corrigendum_in_id assigned_in_id + parent_id correct_name_id basonym_id redirect_id genome_strain + ncbi_taxonomy lpsn_url gtdb_accession algaebase_species algaebase_taxonomy + wikidata_item wikispecies_entry + ].concat(Name::ETYMOLOGY_COLUMNS).freeze + # deleted_at is shown as whether the publication or placement is linked. + PUBLICATION_NAME_KEYS = %w[deleted_at emends not_valid_proposal].freeze + # Only these rich texts of the name appear in the public history. + RICH_TEXT_NAMES = %w[description etymology_text].freeze + PLACEMENT_KEYS = %w[deleted_at parent_id incertae_sedis publication_id preferred].freeze + + def initialize(name) + @name = name + end + + # Load all histories and filter displayable changes in Ruby before pagination. + # Each version's record is loaded up front to label link changes. + def versions(page:) + name_versions.or(publication_versions).or(placement_versions).or(rich_text_versions) + .includes(:record, :user) + .order(created_at: :desc, id: :desc) + .select { |version| changes_for(version).any? } + .paginate(page: page, per_page: 25) + end + + def changes_for(version) + case version.record_type + when 'Name' + name_changes_for(version) + when 'PublicationName' + publication_changes_for(version) + when 'Placement' + placement_changes_for(version) + when 'ActionText::RichText' + rich_text_changes_for(version) + else + raise ArgumentError, "Unsupported public history record type: #{version.record_type}" + end + end + + private + + def name_versions + @name.versions.where(operation: 'update') + end + + # Group etymology and nomenclatural type attributes before removing unchanged + # companions, so each side retains the context needed to render it. + def name_changes_for(version) + changes = version.changeset.slice(*NAME_KEYS) + etymology = changes.extract!(*Name::ETYMOLOGY_COLUMNS) + if etymology.any? + changes['etymology'] = etymology_snapshots(etymology, changes['name']) + end + nomenclatural_type = changes.extract!('nomenclatural_type_type', 'nomenclatural_type_id') + if nomenclatural_type.any? + changes['nomenclatural_type'] = nomenclatural_type_references(nomenclatural_type) + end + # versioned_together records unchanged companion values too. + changes.reject! { |_, values| values.first == values.last } + changes + end + + def rich_text_versions + Version.where( + record_type: 'ActionText::RichText', + record_id: ActionText::RichText.where( + record: @name, name: RICH_TEXT_NAMES + ).select(:id) + ) + end + + # A rich text version is shown as a change of the name's attribute it holds, + # found in the changeset of a created or deleted rich text, or else on the + # rich text itself: + # { 'description' => [nil, '
A gut bacterium
'] } + def rich_text_changes_for(version) + body = version.changeset['body'] + attribute = version.changeset['name']&.compact&.first || version.record&.name + return {} unless body && body.first != body.last && RICH_TEXT_NAMES.include?(attribute) + + { attribute => body } + end + + def publication_versions + Version.where( + record_type: 'PublicationName', record_id: @name.all_publication_names.select(:id) + ) + end + + def publication_changes_for(version) + link_record_changes(version, PUBLICATION_NAME_KEYS) + end + + def placement_versions + Version.where(record_type: 'Placement', record_id: @name.all_placements.select(:id)) + end + + def placement_changes_for(version) + link_record_changes(version, PLACEMENT_KEYS) + end + + # Linking a record is a create and unlinking or relinking is an update of + # deleted_at; both are shown only as a change of 'linked': + # { 'linked' => [false, true] } + # Other updates show the changed attributes: + # { 'emends' => [true, false] } + def link_record_changes(version, keys) + case version.operation + when 'create' + { 'linked' => [false, true] } + when 'update' + changes = version.changeset.slice(*keys) + # A nil deleted_at means the record is linked. + linked = changes.delete('deleted_at')&.map(&:nil?) + # Changing the timestamp of an already unlinked record is not a link change. + if linked && linked.first != linked.last + { 'linked' => linked } + else + changes + end + else + {} + end + end + + # Pair the type and ID on each side: + # [['Strain', 1], ['Genome', 2]] + def nomenclatural_type_references(nomenclatural_type) + types = nomenclatural_type.fetch('nomenclatural_type_type', [nil, nil]) + ids = nomenclatural_type.fetch('nomenclatural_type_id', [nil, nil]) + types.zip(ids) + end + + # Include the name because full_etymology derives the :xx particle from its + # last word. + def etymology_snapshots(etymology, names) + before_name, after_name = names + before_attributes = etymology.transform_values(&:first) + after_attributes = etymology.transform_values(&:last) + + [ + before_attributes.merge('name' => before_name), + after_attributes.merge('name' => after_name) + ] + end +end diff --git a/app/models/placement.rb b/app/models/placement.rb index 9c0e7e0f..c2dec3e6 100644 --- a/app/models/placement.rb +++ b/app/models/placement.rb @@ -1,4 +1,7 @@ class Placement < ApplicationRecord + include Versioned + include SoftDeletable + belongs_to(:name) belongs_to( :parent, optional: true, class_name: 'Name', foreign_key: 'parent_id' @@ -12,7 +15,12 @@ class Placement < ApplicationRecord validates( :incertae_sedis, inclusion: { in: [true, false] } ) - validates(:preferred, uniqueness: { scope: :name_id, if: :preferred? }) + validates( + :preferred, + uniqueness: { + scope: :name_id, conditions: -> { where(deleted_at: nil) }, if: :preferred? + } + ) after_save(:harmonize_name_parent) @@ -46,6 +54,6 @@ def downwards? private def harmonize_name_parent - name.update(parent: incertae_sedis? ? nil : parent) if preferred + name.update(parent: incertae_sedis? ? nil : parent) if preferred && !soft_deleted? end end diff --git a/app/models/publication.rb b/app/models/publication.rb index 5ab374dc..ffb262bb 100644 --- a/app/models/publication.rb +++ b/app/models/publication.rb @@ -18,7 +18,9 @@ class Publication < ApplicationRecord dependent: :destroy ) has_many(:publication_subjects, dependent: :destroy) - has_many(:publication_names, dependent: :destroy) + has_many(:publication_names) + has_many(:all_publication_names, -> { with_deleted }, + class_name: 'PublicationName', dependent: :destroy, inverse_of: :publication) has_many(:authors, through: :publication_authors) has_many(:subjects, through: :publication_subjects) has_many(:names, through: :publication_names) @@ -38,7 +40,9 @@ class Publication < ApplicationRecord foreign_key: 'assigned_in_id', inverse_of: :assigned_in, dependent: :nullify ) - has_many(:placements, dependent: :nullify) + has_many(:placements) + has_many(:all_placements, -> { with_deleted }, + class_name: 'Placement', dependent: :nullify, inverse_of: :publication) has_many(:contacts) class << self @@ -303,6 +307,10 @@ def short_citation(format = :text) "#{authors_et_al(format)}, #{journal_date.year}" end + def display(_html = true) + short_citation + end + def citation "#{short_citation}, #{journal_raw || pub_type.tr('-', ' ')}" end diff --git a/app/models/publication_name.rb b/app/models/publication_name.rb index 4d49c06d..4c60a99d 100644 --- a/app/models/publication_name.rb +++ b/app/models/publication_name.rb @@ -1,7 +1,14 @@ class PublicationName < ApplicationRecord + include Versioned + include SoftDeletable + belongs_to(:publication) belongs_to(:name) + def display(html = true) + publication.display(html) + end + def proposes? name.proposed_in? publication end diff --git a/app/models/version.rb b/app/models/version.rb new file mode 100644 index 00000000..37a6ef63 --- /dev/null +++ b/app/models/version.rb @@ -0,0 +1,6 @@ +class Version < ApplicationRecord + # Historical records remain accessible when their model retains soft-deleted rows. + belongs_to :record, -> { respond_to?(:with_deleted) ? with_deleted : all }, + polymorphic: true, optional: true + belongs_to :user, optional: true +end diff --git a/app/views/names/_title.html.erb b/app/views/names/_title.html.erb index 636bf2e2..7af3e1fd 100644 --- a/app/views/names/_title.html.erb +++ b/app/views/names/_title.html.erb @@ -18,6 +18,7 @@ %> <%= download_buttons([ + [name_history_path(@name), 'history', 'History'], [name_url(@name, format: :json), 'file-code', 'JSON'], [name_url(@name, format: :pdf), 'file-pdf', 'PDF'], observe @@ -78,4 +79,3 @@ <% end %>
- diff --git a/app/views/names/histories/_author.html.erb b/app/views/names/histories/_author.html.erb new file mode 100644 index 00000000..2ceb1367 --- /dev/null +++ b/app/views/names/histories/_author.html.erb @@ -0,0 +1,6 @@ +<% if version.user %> + <%= fa_icon('user') %> + <%= link_to(version.user.username, version.user, title: version.user.display_name) %> +<% else %> + — +<% end %> diff --git a/app/views/names/histories/show.html.erb b/app/views/names/histories/show.html.erb new file mode 100644 index 00000000..3830bf30 --- /dev/null +++ b/app/views/names/histories/show.html.erb @@ -0,0 +1,64 @@ +<% provide(:title, "History of #{@name.abbr_name_raw}") %> + +

History of <%= @name.name_html %>

+

<%= link_to('Back to name', @name) %>

+ +<% if @versions.empty? %> +

No history has been recorded for this name.

+<% else %> +
+ + + + + + + + + + + <% @versions.each do |version| %> + <% changes = @history.changes_for(version) %> + <% record_type = history_record_type(version) %> + <% record_change = record_type != 'Name' %> + + <% if record_change %> + + + + + + <% end %> + <% changes.each_with_index do |(attribute, values), index| %> + <% before, after = history_values(values, attribute: attribute, record_type: record_type) %> + + <% if index.zero? && !record_change %> + + + <% end %> + + + + + <% end %> + + <% end %> +
DateByAttributeBeforeAfter
+ + + <%= render('names/histories/author', version: version) %> + + <%= history_record_heading(version) %> +
+ + + <%= render('names/histories/author', version: version) %> + <%= record_type.constantize.human_attribute_name(attribute) %><%= before %><%= after %>
+
+<% end %> + +<%= will_paginate(@versions) %> diff --git a/config/initializers/action_text_versions.rb b/config/initializers/action_text_versions.rb new file mode 100644 index 00000000..3027b2fb --- /dev/null +++ b/config/initializers/action_text_versions.rb @@ -0,0 +1,4 @@ +# Rich text lives in its own records, so version them like any other record. +Rails.application.config.to_prepare do + ActionText::RichText.include(Versioned) +end diff --git a/config/routes.rb b/config/routes.rb index 46b9ccae..e2717786 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -127,6 +127,7 @@ path: '/not_validly_proposed_in/:publication_id' end resources :pseudonyms + resource(:history, only: :show, module: :names) end # Taxonomic placements diff --git a/db/migrate/20260917100000_create_versions.rb b/db/migrate/20260917100000_create_versions.rb new file mode 100644 index 00000000..872f2298 --- /dev/null +++ b/db/migrate/20260917100000_create_versions.rb @@ -0,0 +1,11 @@ +class CreateVersions < ActiveRecord::Migration[6.1] + def change + create_table :versions do |t| + t.references :record, polymorphic: true, null: false + t.references :user, foreign_key: true + t.string :operation, null: false + t.jsonb :changeset, null: false + t.datetime :created_at, null: false + end + end +end diff --git a/db/migrate/20261002100000_add_deleted_at_to_name_relationships.rb b/db/migrate/20261002100000_add_deleted_at_to_name_relationships.rb new file mode 100644 index 00000000..d274006d --- /dev/null +++ b/db/migrate/20261002100000_add_deleted_at_to_name_relationships.rb @@ -0,0 +1,10 @@ +class AddDeletedAtToNameRelationships < ActiveRecord::Migration[6.1] + def change + add_column :publication_names, :deleted_at, :datetime + remove_index :publication_names, column: %i[publication_id name_id], unique: true + add_index :publication_names, %i[publication_id name_id], + unique: true, where: 'deleted_at IS NULL' + + add_column :placements, :deleted_at, :datetime + end +end diff --git a/db/migrate/20261029120000_enforce_one_active_preferred_placement.rb b/db/migrate/20261029120000_enforce_one_active_preferred_placement.rb new file mode 100644 index 00000000..92a0c770 --- /dev/null +++ b/db/migrate/20261029120000_enforce_one_active_preferred_placement.rb @@ -0,0 +1,7 @@ +class EnforceOneActivePreferredPlacement < ActiveRecord::Migration[6.1] + def change + add_index :placements, :name_id, unique: true, + where: 'preferred = TRUE AND deleted_at IS NULL', + name: 'index_placements_on_active_preferred_name' + end +end diff --git a/db/schema.rb b/db/schema.rb index 2ff306ca..391a6996 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2026_10_25_100000) do +ActiveRecord::Schema.define(version: 2026_10_29_120000) do # These are extensions that must be enabled in order to support this database enable_extension "fuzzystrmatch" @@ -353,7 +353,9 @@ t.string "incertae_sedis_legacy" t.boolean "gtdb_taxonomy" t.boolean "ncbi_taxonomy" + t.datetime "deleted_at" t.boolean "incertae_sedis", default: false, null: false + t.index ["name_id"], name: "index_placements_on_active_preferred_name", unique: true, where: "((preferred = true) AND (deleted_at IS NULL))" t.index ["name_id"], name: "index_placements_on_name_id" t.index ["publication_id"], name: "index_placements_on_publication_id" end @@ -387,8 +389,9 @@ t.datetime "updated_at", null: false t.boolean "emends", default: false t.boolean "not_valid_proposal", default: false + t.datetime "deleted_at" t.index ["name_id"], name: "index_publication_names_on_name_id" - t.index ["publication_id", "name_id"], name: "index_publication_names_on_publication_id_and_name_id", unique: true + t.index ["publication_id", "name_id"], name: "index_publication_names_on_publication_id_and_name_id", unique: true, where: "(deleted_at IS NULL)" t.index ["publication_id"], name: "index_publication_names_on_publication_id" end @@ -592,6 +595,17 @@ t.index ["username"], name: "index_users_on_username", unique: true end + create_table "versions", force: :cascade do |t| + t.string "record_type", null: false + t.bigint "record_id", null: false + t.string "operation", null: false + t.jsonb "changeset", null: false + t.datetime "created_at", null: false + t.bigint "user_id" + t.index ["record_type", "record_id"], name: "index_versions_on_record" + t.index ["user_id"], name: "index_versions_on_user_id" + end + create_table "wikispecies_credentials", force: :cascade do |t| t.bigint "user_id", null: false t.text "encrypted_access_token", null: false @@ -642,5 +656,6 @@ add_foreign_key "reports", "users" add_foreign_key "tags", "tags", column: "parent_id" add_foreign_key "tutorials", "users" + add_foreign_key "versions", "users" add_foreign_key "wikispecies_credentials", "users" end diff --git a/lib/html_diff.rb b/lib/html_diff.rb new file mode 100644 index 00000000..c5b82496 --- /dev/null +++ b/lib/html_diff.rb @@ -0,0 +1,45 @@ +require 'diff/lcs' + +module HtmlDiff + TOKEN = /<[^>]*>|\s+|[^<\s]+/ + TAG = /\A 'text-danger', 'ins' => 'text-success' }.freeze + + # Returns [before_html, after_html]: each side's own HTML with its changed + # text wrapped in or . Tags are compared but never wrapped, so each + # side keeps its own well-formed markup. The result is not sanitized. For an + # E. coli description edit, '
A gut bacterium
' -> + # '
A commensal gut bacterium
' gives + # ['
A gut bacterium
', '
A commensal gut bacterium
']. + def self.diff(before, after) + changes = Diff::LCS.sdiff(tokens(before), tokens(after)) + [ + render(changes.filter_map { |c| [c.old_element, c.action != '='] if c.old_element }, 'del'), + render(changes.filter_map { |c| [c.new_element, c.action != '='] if c.new_element }, 'ins') + ] + end + + def self.tokens(html) + html.to_s.scan(TOKEN) + end + + # Wrap each run of changed text tokens, absorbing unchanged whitespace between + # two changed runs so an edited phrase is marked as one. + def self.render(tokens, tag) + runs = tokens.chunk_while { |a, b| a[1] == b[1] && text?(a[0]) == text?(b[0]) }.to_a + runs.each_with_index do |run, index| + next unless index.positive? && index < runs.size - 1 && run.all? { |t, changed| !changed && t.match?(/\A\s+\z/) } + + before, after = runs[index - 1], runs[index + 1] + run.each { |token| token[1] = true } if before.all? { |t, c| c && text?(t) } && after.all? { |t, c| c && text?(t) } + end + runs.flatten(1).chunk_while { |a, b| a[1] == b[1] && text?(a[0]) == text?(b[0]) }.map do |run| + html = run.map(&:first).join + run.first[1] && text?(run.first[0]) && html.match?(/\S/) ? %(<#{tag} class="#{CLASSES[tag]}">#{html}) : html + end.join + end + + def self.text?(token) + !token.match?(TAG) + end +end diff --git a/lib/text_diff.rb b/lib/text_diff.rb new file mode 100644 index 00000000..e19acd46 --- /dev/null +++ b/lib/text_diff.rb @@ -0,0 +1,20 @@ +require 'diff/lcs' + +module TextDiff + # Returns [unchanged, before_text, after_text] chunks. For an E. coli etymology + # edit, 'of the colon' -> 'from the colon' gives: + # [[false, 'of', 'from'], [true, ' the colon', ' the colon']]. + def self.chunks(before, after) + changes = Diff::LCS.sdiff(before.to_s.scan(/\X/), after.to_s.scan(/\X/)) + # Group characters into unchanged/changed runs, combining additions and deletions. + runs = changes.chunk { |change| change.action == '=' }.to_a + # Absorb unchanged islands of up to two characters between edits; keep the ends. + runs[1...-1].to_a.each { |run| run[0] = false if run[1].size <= 2 } + # Merge the resulting runs into contiguous text chunks. + runs.chunk_while { |a, b| a[0] == b[0] }.map do |group| + chunk = group.flat_map(&:last) + [group.first[0], chunk.map(&:old_element).compact.join, + chunk.map(&:new_element).compact.join] + end + end +end diff --git a/test/controllers/names/histories_controller_test.rb b/test/controllers/names/histories_controller_test.rb new file mode 100644 index 00000000..30992bf3 --- /dev/null +++ b/test/controllers/names/histories_controller_test.rb @@ -0,0 +1,28 @@ +require 'test_helper' + +class Names::HistoriesControllerTest < ActionDispatch::IntegrationTest + include Devise::Test::IntegrationHelpers + + test 'renders public history' do + name = names(:escherichia_coli) + name.update!(authority: 'Smith', etymology_xx_description: 'of the colon') + PublicationName.create!(name: name, publication: publications(:one)) + + get name_history_path(name) + + assert_response :success + assert_select '.name-history-version', count: 2 + end + + test 'does not reveal private name history' do + name = Name.create!( + name: 'E. coli', rank: 'species', status: 5, created_by: users(:contributor) + ) + name.update!(syllabication: 'co.li') + + get name_history_path(name) + + assert_select 'h3', text: 'Insufficient permits' + assert_select '.name-history-version', count: 0 + end +end diff --git a/test/controllers/placements_controller_test.rb b/test/controllers/placements_controller_test.rb index 3df4ffc8..9ff5ec67 100644 --- a/test/controllers/placements_controller_test.rb +++ b/test/controllers/placements_controller_test.rb @@ -52,6 +52,20 @@ class PlacementsControllerTest < ActionDispatch::IntegrationTest assert_equal names(:escherichia), @name.parent end + test 'unlinks an alternative placement instead of deleting it' do + name = names(:escherichia_coli) + placement = Placement.create!( + name: name, parent: names(:bacteria), incertae_sedis: true, + incertae_sedis_text: 'Family placement unresolved' + ) + + delete placement_path(placement) + + assert_redirected_to name_path(name) + assert placement.reload.soft_deleted? + assert_not_includes name.reload.alt_placements, placement + end + test 'removing an alternative placement keeps the name parent' do name = names(:escherichia_coli) placement = Placement.create!( diff --git a/test/controllers/publications_controller_test.rb b/test/controllers/publications_controller_test.rb index dd9c247d..5dc14c29 100644 --- a/test/controllers/publications_controller_test.rb +++ b/test/controllers/publications_controller_test.rb @@ -166,4 +166,22 @@ class PublicationsControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_includes @response.body, 'New Publication' end + + test 'registering an emending publication recreates an unlinked relationship' do + sign_in(users(:curator)) + name = names(:escherichia_coli) + link = PublicationName.create!(name: name, publication: @publication) + link.soft_delete! + + post publications_url, params: { + publication: { doi: "id:#{@publication.id}" }, + link_name: { id: name.id, as: 'emend' } + } + + assert_response :redirect + current_link = name.publication_names.find_by!(publication: @publication) + assert current_link.emends? + assert_not_equal link.id, current_link.id + assert_not link.reload.emends? + end end diff --git a/test/helpers/names/histories_helper_test.rb b/test/helpers/names/histories_helper_test.rb new file mode 100644 index 00000000..72b6f6c7 --- /dev/null +++ b/test/helpers/names/histories_helper_test.rb @@ -0,0 +1,86 @@ +require 'test_helper' + +class Names::HistoriesHelperTest < ActionView::TestCase + test 'groups nearby etymology edits into one span' do + changes = [ + { name: 'E. coli', etymology_xx_description: 'of the colon' }, + { name: 'E. coli', etymology_xx_description: 'from the colon' } + ] + before, after = history_values(changes, attribute: 'etymology') + + assert_equal 'coli, of the colon', before + assert_equal 'coli, from the colon', after + end + + test 'diffs rich text as sanitized HTML' do + changes = ['
A gut bacterium
', + '
A gut bacterium E. coli
'] + before, after = history_values(changes, attribute: 'description') + + assert_equal '
A gut bacterium
', before + assert_not_includes after, '