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 %>
+
+
+
+
+ | Date |
+ By |
+ Attribute |
+ Before |
+ After |
+
+
+ <% @versions.each do |version| %>
+ <% changes = @history.changes_for(version) %>
+ <% record_type = history_record_type(version) %>
+ <% record_change = record_type != 'Name' %>
+
+ <% if record_change %>
+
+ |
+
+ |
+
+ <%= render('names/histories/author', version: version) %>
+ |
+
+ <%= history_record_heading(version) %>
+ |
+
+ <% 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 %>
+ |
+
+ |
+
+ <%= render('names/histories/author', version: version) %>
+ |
+ <% end %>
+ <%= record_type.constantize.human_attribute_name(attribute) %> |
+ <%= before %> |
+ <%= after %> |
+
+ <% end %>
+
+ <% end %>
+
+
+<% 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
+ CLASSES = { 'del' => '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}#{tag}>) : 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, '