From 6ec7cd702a43ba8134ba93a96ff43477df424dd6 Mon Sep 17 00:00:00 2001 From: vangberg Date: Thu, 17 Sep 2026 10:07:43 +0200 Subject: [PATCH 01/12] Add minimal Versioned concern and enable it for Name --- app/models/concerns/versioned.rb | 26 +++++++ app/models/name.rb | 1 + app/models/version.rb | 3 + db/migrate/20260917100000_create_versions.rb | 10 +++ db/schema.rb | 11 ++- test/models/name/versioned_test.rb | 77 ++++++++++++++++++++ 6 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 app/models/concerns/versioned.rb create mode 100644 app/models/version.rb create mode 100644 db/migrate/20260917100000_create_versions.rb create mode 100644 test/models/name/versioned_test.rb diff --git a/app/models/concerns/versioned.rb b/app/models/concerns/versioned.rb new file mode 100644 index 00000000..ac6b7b74 --- /dev/null +++ b/app/models/concerns/versioned.rb @@ -0,0 +1,26 @@ +module Versioned + extend ActiveSupport::Concern + + included do + has_many :versions, as: :record + + after_create :record_create_version + after_update :record_update_version + after_destroy :record_delete_version + end + + private + + def record_create_version + versions.create!(operation: 'create', changeset: {}) + end + + def record_update_version + changeset = saved_changes.except('created_at', 'updated_at') + versions.create!(operation: 'update', changeset: changeset) if changeset.any? + end + + def record_delete_version + Version.create!(record: self, operation: 'delete', changeset: {}) + end +end diff --git a/app/models/name.rb b/app/models/name.rb index 9fef19ca..4e950b22 100644 --- a/app/models/name.rb +++ b/app/models/name.rb @@ -147,6 +147,7 @@ class Name < ApplicationRecord include Name::Wiki include Name::FuzzySearch include TypeMaterial + include Versioned attr_accessor :only_display attr_accessor :nomenclatural_type_entry diff --git a/app/models/version.rb b/app/models/version.rb new file mode 100644 index 00000000..588d1a2a --- /dev/null +++ b/app/models/version.rb @@ -0,0 +1,3 @@ +class Version < ApplicationRecord + belongs_to :record, polymorphic: true, optional: true +end diff --git a/db/migrate/20260917100000_create_versions.rb b/db/migrate/20260917100000_create_versions.rb new file mode 100644 index 00000000..886bd899 --- /dev/null +++ b/db/migrate/20260917100000_create_versions.rb @@ -0,0 +1,10 @@ +class CreateVersions < ActiveRecord::Migration[6.1] + def change + create_table :versions do |t| + t.references :record, polymorphic: true, null: false + t.string :operation, null: false + t.jsonb :changeset, null: false + t.datetime :created_at, null: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 6f900aa0..e166d71f 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_08_19_100000) do +ActiveRecord::Schema.define(version: 2026_09_17_100000) do # These are extensions that must be enabled in order to support this database enable_extension "fuzzystrmatch" @@ -568,6 +568,15 @@ 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.index ["record_type", "record_id"], name: "index_versions_on_record" + end + create_table "wikispecies_credentials", force: :cascade do |t| t.bigint "user_id", null: false t.text "encrypted_access_token", null: false diff --git a/test/models/name/versioned_test.rb b/test/models/name/versioned_test.rb new file mode 100644 index 00000000..358e88c8 --- /dev/null +++ b/test/models/name/versioned_test.rb @@ -0,0 +1,77 @@ +require 'test_helper' + +class Name::VersionedTest < ActiveSupport::TestCase + setup do + @name = Name.new(name: 'E. coli', rank: 'species') + end + + test 'creating a name records an empty changeset' do + assert_difference -> { @name.versions.count } do + @name.save! + end + + version = @name.versions.last + assert_equal 'create', version.operation + assert_empty version.changeset + end + + test 'updating a name records only changed attributes' do + @name.save! + + assert_difference -> { @name.versions.count } do + @name.update!(syllabication: 'co.li') + end + + version = @name.versions.last + assert_equal 'update', version.operation + assert_equal [nil, 'co.li'], version.changeset['syllabication'] + assert_equal %w[syllabication], version.changeset.keys + end + + test 'updating only timestamps does not add a version' do + @name.save! + + assert_no_difference -> { @name.versions.count } do + @name.update!(created_at: 1.day.ago, updated_at: 1.hour.ago) + end + end + + test 'saving an unchanged name does not add a version' do + @name.save! + + assert_no_difference -> { @name.versions.count } do + @name.save! + end + end + + test 'deleting a name records an empty changeset' do + @name.save! + versions = Version.where(record: @name) + + assert_difference -> { versions.count } do + @name.destroy! + end + + version = versions.last + assert_equal 'delete', version.operation + assert_empty version.changeset + end + + test 'deleting a name retains its history' do + @name.save! + version = @name.versions.last + + @name.destroy! + + assert Version.exists?(version.id) + end + + test 'versions roll back with the name' do + assert_no_difference -> { Version.count } do + Name.transaction do + @name.save! + raise ActiveRecord::Rollback + end + end + end +end From 1d3878d71544231b47051da366a873d3c838453c Mon Sep 17 00:00:00 2001 From: vangberg Date: Thu, 17 Sep 2026 10:57:46 +0200 Subject: [PATCH 02/12] Record related Name attributes together --- app/models/concerns/versioned.rb | 38 +++++++++++++++++++++++++++++- app/models/name.rb | 3 +++ test/models/name/versioned_test.rb | 15 ++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/app/models/concerns/versioned.rb b/app/models/concerns/versioned.rb index ac6b7b74..d0a8ed37 100644 --- a/app/models/concerns/versioned.rb +++ b/app/models/concerns/versioned.rb @@ -3,12 +3,20 @@ module Versioned 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 record_create_version @@ -16,11 +24,39 @@ def record_create_version end def record_update_version - changeset = saved_changes.except('created_at', 'updated_at') + changeset = versioned_changes versions.create!(operation: 'update', changeset: changeset) if changeset.any? end def record_delete_version + # The owner is already destroyed, so create directly rather than through + # its association. Deletion records only the event and retains history. Version.create!(record: self, operation: 'delete', changeset: {}) end + + # An update changeset maps each changed attribute to [before, after], e.g. + # { 'syllabication' => [nil, 'co.li'] } for E. coli. + # Create and delete versions have an empty changeset; timestamps are omitted. + def versioned_changes + changeset = saved_changes.except('created_at', 'updated_at') + + changed_columns = changeset.keys + self.class.versioned_attribute_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/name.rb b/app/models/name.rb index 4e950b22..c6a240ff 100644 --- a/app/models/name.rb +++ b/app/models/name.rb @@ -149,6 +149,9 @@ class Name < ApplicationRecord 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 attr_accessor :qc_for_tutorial diff --git a/test/models/name/versioned_test.rb b/test/models/name/versioned_test.rb index 358e88c8..f1c18178 100644 --- a/test/models/name/versioned_test.rb +++ b/test/models/name/versioned_test.rb @@ -36,6 +36,21 @@ class Name::VersionedTest < ActiveSupport::TestCase end end + test 'changing an etymology description snapshots valued etymology attributes' do + @name.assign_attributes( + etymology_xx_lang: 'N.L.', etymology_xx_description: 'of the colon' + ) + @name.save! + + @name.update!(etymology_xx_description: 'from the colon') + + changeset = @name.versions.last.changeset + assert_equal %w[etymology_xx_description etymology_xx_lang name], changeset.keys.sort + assert_equal ['of the colon', 'from the colon'], changeset['etymology_xx_description'] + assert_equal ['N.L.', 'N.L.'], changeset['etymology_xx_lang'] + assert_equal ['E. coli', 'E. coli'], changeset['name'] + end + test 'saving an unchanged name does not add a version' do @name.save! From 9afc744b2e4e08448f57986250d87c25bcf0929f Mon Sep 17 00:00:00 2001 From: vangberg Date: Thu, 17 Sep 2026 12:03:22 +0200 Subject: [PATCH 03/12] Add public name history view --- Gemfile | 1 + Gemfile.lock | 2 + app/assets/stylesheets/names.scss | 61 ++++++++++++++++++++++++++ app/controllers/names_controller.rb | 9 +++- app/helpers/names_helper.rb | 29 ++++++++++++ app/models/name/public_history.rb | 52 ++++++++++++++++++++++ app/views/names/_title.html.erb | 2 +- app/views/names/history.html.erb | 34 ++++++++++++++ config/routes.rb | 1 + test/controllers/names/history_test.rb | 31 +++++++++++++ 10 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 app/models/name/public_history.rb create mode 100644 app/views/names/history.html.erb create mode 100644 test/controllers/names/history_test.rb 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 9e1cbdb6..27949cdc 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.4) erubi (1.13.1) @@ -487,6 +488,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..c88ec0d7 100644 --- a/app/assets/stylesheets/names.scss +++ b/app/assets/stylesheets/names.scss @@ -16,3 +16,64 @@ } } +.name-history-version { + display: grid; + grid-template-columns: 11rem minmax(0, 1fr); + gap: 1rem; + padding: 0.65rem 0.75rem; + + .name-history-date { + color: #6c757d; + font-size: 0.875rem; + white-space: nowrap; + } +} + +.name-history-change { + display: grid; + grid-template-columns: 10rem minmax(0, 1fr); + gap: 0.5rem; + padding: 0.3rem 0.5rem; + + &:nth-child(even) { + background-color: #f7f8fa; + } +} + +.name-history-values { + display: grid; + grid-template-columns: minmax(0, 1fr) 2rem minmax(0, 1fr); + align-items: center; + min-width: 0; + overflow-wrap: anywhere; +} + +.name-history-before, +.name-history-after { + white-space: pre-wrap; +} + +.name-history-arrow { + color: #6c757d; + text-align: center; +} + +@media (max-width: 767px) { + .name-history-version { + display: block; + + .name-history-date { + display: block; + margin-bottom: 0.2rem; + } + } + + .name-history-change { + display: block; + + > strong { + display: block; + margin-bottom: 0.15rem; + } + } +} diff --git a/app/controllers/names_controller.rb b/app/controllers/names_controller.rb index a7c26ee3..c9870b03 100644 --- a/app/controllers/names_controller.rb +++ b/app/controllers/names_controller.rb @@ -12,7 +12,7 @@ class NamesController < ApplicationController before_action( :set_name, only: %i[ - edit update destroy network wiki + edit update destroy network wiki history proposed_in not_validly_proposed_in emended_in assigned_in corrigendum_in corrigendum_orphan corrigendum edit_description edit_rank edit_notes edit_etymology edit_links edit_type @@ -215,6 +215,13 @@ def show end end + # GET /names/1/history + def history + @history = Name::PublicHistory.new(@name) + @versions = @history.versions(page: params[:page]) + @crumbs = [['Names', names_path], [@name.abbr_name, @name], 'History'] + end + # GET /names/linkout.xml # GET /names/1/linkout.xml def linkout diff --git a/app/helpers/names_helper.rb b/app/helpers/names_helper.rb index 0fd59158..038f87bc 100644 --- a/app/helpers/names_helper.rb +++ b/app/helpers/names_helper.rb @@ -1,4 +1,30 @@ +require 'diff/lcs' + module NamesHelper + def history_before_after_diff(before, after, attribute:) + if attribute == 'etymology' + before = Name.new(before).full_etymology + after = Name.new(after).full_etymology + end + + changes = Diff::LCS.sdiff(before.to_s.scan(/\X/), after.to_s.scan(/\X/)) + + before_parts = changes.filter_map do |change| + case change.action + when '=' then change.old_element + when '-', '!' then content_tag(:del, change.old_element, class: 'text-danger') + end + end + after_parts = changes.filter_map do |change| + case change.action + when '=' then change.new_element + when '+', '!' then content_tag(:ins, change.new_element, class: 'text-success') + end + end + + [safe_join(before_parts).presence || '—', safe_join(after_parts).presence || '—'] + end + def link_to_name_type(name) if name.type_is_name? if name.type_name @@ -64,4 +90,7 @@ def name_lineage(name, links: true, last: true, register: nil, visited: []) out.inject(:+) end + + private + end diff --git a/app/models/name/public_history.rb b/app/models/name/public_history.rb new file mode 100644 index 00000000..09ba3392 --- /dev/null +++ b/app/models/name/public_history.rb @@ -0,0 +1,52 @@ +class Name::PublicHistory + # Only these recorded attributes appear in the public history. + KEYS = %w[ + name rank syllabication status priority_date authority + nomenclatural_status taxonomic_status proposal_kind corrigendum_from + type_material type_accession + ].concat(Name::ETYMOLOGY_COLUMNS).freeze + + def initialize(name) + @name = name + end + + # Filter before paginating so each page contains public updates only. + def versions(page:) + @name.versions + .where(operation: 'update') + .where( + 'EXISTS (SELECT 1 FROM jsonb_object_keys(versions.changeset) AS key WHERE key IN (?))', + KEYS + ) + .order(created_at: :desc, id: :desc) + .paginate(page: page, per_page: 25) + end + + # Ordinary fields keep their [before, after] pairs. Etymology fields become + # one pair of attribute snapshots for the view to render together. + def changes_for(version) + changes = version.changeset.slice(*KEYS) + etymology = changes.extract!(*Name::ETYMOLOGY_COLUMNS) + if etymology.any? + changes['etymology'] = etymology_snapshots(etymology, changes['name']) + end + # versioned_together records unchanged companion values too. + changes.reject! { |_, values| values.first == values.last } + changes + end + + private + + # Include the name because full_etymology derives the :xx particle from its + # last word. Older versions without a name pair use the current name. + def etymology_snapshots(etymology, names) + before_name, after_name = names || [@name.name, @name.name] + 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/views/names/_title.html.erb b/app/views/names/_title.html.erb index 636bf2e2..b85062bb 100644 --- a/app/views/names/_title.html.erb +++ b/app/views/names/_title.html.erb @@ -18,6 +18,7 @@ %> <%= download_buttons([ + [history_name_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/history.html.erb b/app/views/names/history.html.erb new file mode 100644 index 00000000..cea97867 --- /dev/null +++ b/app/views/names/history.html.erb @@ -0,0 +1,34 @@ +<% 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| %> +
+ + +
+ <% @history.changes_for(version).each do |attribute, (before, after)| %> + <% before_diff, after_diff = history_before_after_diff(before, after, attribute: attribute) %> +
+ <%= Name.human_attribute_name(attribute) %>: + + Before: <%= before_diff %> + + After: <%= after_diff %> + +
+ <% end %> +
+
+ <% end %> +
+<% end %> + +<%= will_paginate(@versions) %> diff --git a/config/routes.rb b/config/routes.rb index d8b5962c..f10d0dea 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -86,6 +86,7 @@ end member do # --> Display name + get :history get :network get :linkout get :wiki diff --git a/test/controllers/names/history_test.rb b/test/controllers/names/history_test.rb new file mode 100644 index 00000000..4fbfcad2 --- /dev/null +++ b/test/controllers/names/history_test.rb @@ -0,0 +1,31 @@ +require 'test_helper' + +class NamesHistoryTest < ActionDispatch::IntegrationTest + test 'shows public name history' do + name = names(:escherichia_coli) + name.update!(authority: 'Smith', etymology_xx_description: 'of the colon') + + get history_name_path(name) + + assert_response :success + assert_select '.name-history-version', count: 1 + assert_select '.name-history-change', count: 2 + end + + test 'shows an empty state without recorded history' do + get history_name_path(names(:escherichia_coli)) + + assert_response :success + assert_select 'p', text: 'No history has been recorded for this name.' + end + + test 'does not reveal private name history' do + name = names(:draft_by_contributor) + name.update!(syllabication: 'co.li') + + get history_name_path(name) + + assert_select 'h3', text: 'Insufficient permits' + assert_select '.name-history-version', count: 0 + end +end From 53e736ab682e52168ee4697430b4b1c5bfcd9013 Mon Sep 17 00:00:00 2001 From: vangberg Date: Fri, 25 Sep 2026 10:50:42 +0200 Subject: [PATCH 04/12] Refactor name history view into partials --- app/views/names/history.html.erb | 13 ++----------- app/views/names/history/_name.html.erb | 11 +++++++++++ 2 files changed, 13 insertions(+), 11 deletions(-) create mode 100644 app/views/names/history/_name.html.erb diff --git a/app/views/names/history.html.erb b/app/views/names/history.html.erb index cea97867..790c63a9 100644 --- a/app/views/names/history.html.erb +++ b/app/views/names/history.html.erb @@ -14,17 +14,8 @@
- <% @history.changes_for(version).each do |attribute, (before, after)| %> - <% before_diff, after_diff = history_before_after_diff(before, after, attribute: attribute) %> -
- <%= Name.human_attribute_name(attribute) %>: - - Before: <%= before_diff %> - - After: <%= after_diff %> - -
- <% end %> + <%= render "names/history/#{version.record_type.underscore}", + version: version, history: @history %>
<% end %> diff --git a/app/views/names/history/_name.html.erb b/app/views/names/history/_name.html.erb new file mode 100644 index 00000000..6beacb41 --- /dev/null +++ b/app/views/names/history/_name.html.erb @@ -0,0 +1,11 @@ +<% history.changes_for(version).each do |attribute, (before, after)| %> + <% before_diff, after_diff = history_before_after_diff(before, after, attribute: attribute) %> +
+ <%= Name.human_attribute_name(attribute) %>: + + Before: <%= before_diff %> + + After: <%= after_diff %> + +
+<% end %> From 5d53d8174403849e4af609137eb63979302db875 Mon Sep 17 00:00:00 2001 From: vangberg Date: Fri, 2 Oct 2026 11:39:34 +0200 Subject: [PATCH 05/12] Revert "Refactor name history view into partials" This reverts commit 53e736ab682e52168ee4697430b4b1c5bfcd9013. --- app/views/names/history.html.erb | 13 +++++++++++-- app/views/names/history/_name.html.erb | 11 ----------- 2 files changed, 11 insertions(+), 13 deletions(-) delete mode 100644 app/views/names/history/_name.html.erb diff --git a/app/views/names/history.html.erb b/app/views/names/history.html.erb index 790c63a9..cea97867 100644 --- a/app/views/names/history.html.erb +++ b/app/views/names/history.html.erb @@ -14,8 +14,17 @@
- <%= render "names/history/#{version.record_type.underscore}", - version: version, history: @history %> + <% @history.changes_for(version).each do |attribute, (before, after)| %> + <% before_diff, after_diff = history_before_after_diff(before, after, attribute: attribute) %> +
+ <%= Name.human_attribute_name(attribute) %>: + + Before: <%= before_diff %> + + After: <%= after_diff %> + +
+ <% end %>
<% end %> diff --git a/app/views/names/history/_name.html.erb b/app/views/names/history/_name.html.erb deleted file mode 100644 index 6beacb41..00000000 --- a/app/views/names/history/_name.html.erb +++ /dev/null @@ -1,11 +0,0 @@ -<% history.changes_for(version).each do |attribute, (before, after)| %> - <% before_diff, after_diff = history_before_after_diff(before, after, attribute: attribute) %> -
- <%= Name.human_attribute_name(attribute) %>: - - Before: <%= before_diff %> - - After: <%= after_diff %> - -
-<% end %> From 1862f5ed5c2a28498d930582f4d73062d3c62027 Mon Sep 17 00:00:00 2001 From: vangberg Date: Fri, 2 Oct 2026 12:04:21 +0200 Subject: [PATCH 06/12] Group nearby character edits in name history diffs --- app/helpers/names_helper.rb | 46 +++++++++++++++++++------------ app/views/names/history.html.erb | 7 ++--- lib/text_diff.rb | 20 ++++++++++++++ test/helpers/names_helper_test.rb | 28 +++++++++++++++++++ test/lib/text_diff_test.rb | 39 ++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 21 deletions(-) create mode 100644 lib/text_diff.rb create mode 100644 test/helpers/names_helper_test.rb create mode 100644 test/lib/text_diff_test.rb diff --git a/app/helpers/names_helper.rb b/app/helpers/names_helper.rb index 038f87bc..def28f36 100644 --- a/app/helpers/names_helper.rb +++ b/app/helpers/names_helper.rb @@ -1,28 +1,40 @@ -require 'diff/lcs' - module NamesHelper - def history_before_after_diff(before, after, attribute:) + def history_before_value(changes, attribute:) + before = changes.first if attribute == 'etymology' - before = Name.new(before).full_etymology - after = Name.new(after).full_etymology + before = history_etymology_parts(*changes).first end - changes = Diff::LCS.sdiff(before.to_s.scan(/\X/), after.to_s.scan(/\X/)) + safe_join(Array(before)).presence || '—' + end - before_parts = changes.filter_map do |change| - case change.action - when '=' then change.old_element - when '-', '!' then content_tag(:del, change.old_element, class: 'text-danger') - end + def history_after_value(changes, attribute:) + after = changes.last + if attribute == 'etymology' + after = history_etymology_parts(*changes).last end - after_parts = changes.filter_map do |change| - case change.action - when '=' then change.new_element - when '+', '!' then content_tag(:ins, change.new_element, class: 'text-success') - end + + safe_join(Array(after)).presence || '—' + end + + def history_etymology_parts(before, after) + before = Name.new(before).full_etymology + after = Name.new(after).full_etymology + + 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 - [safe_join(before_parts).presence || '—', safe_join(after_parts).presence || '—'] + [before_parts, after_parts] end def link_to_name_type(name) diff --git a/app/views/names/history.html.erb b/app/views/names/history.html.erb index cea97867..e7def6e5 100644 --- a/app/views/names/history.html.erb +++ b/app/views/names/history.html.erb @@ -14,14 +14,13 @@
- <% @history.changes_for(version).each do |attribute, (before, after)| %> - <% before_diff, after_diff = history_before_after_diff(before, after, attribute: attribute) %> + <% @history.changes_for(version).each do |attribute, changes| %>
<%= Name.human_attribute_name(attribute) %>: - Before: <%= before_diff %> + Before: <%= history_before_value(changes, attribute: attribute) %> - After: <%= after_diff %> + After: <%= history_after_value(changes, attribute: attribute) %>
<% 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/helpers/names_helper_test.rb b/test/helpers/names_helper_test.rb new file mode 100644 index 00000000..924c4520 --- /dev/null +++ b/test/helpers/names_helper_test.rb @@ -0,0 +1,28 @@ +require 'test_helper' + +class NamesHelperTest < 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 = history_before_value(changes, attribute: 'etymology') + after = history_after_value(changes, attribute: 'etymology') + + assert_equal 'coli, of the colon', before + assert_equal 'coli, from the colon', after + end + + test 'keeps distant edits separate and escapes their content' do + changes = [ + { name: 'E. coli', etymology_xx_description: '< E. coli >' }, + { name: 'E. coli', etymology_xx_description: '& E. coli !' } + ] + before = history_before_value(changes, attribute: 'etymology') + after = history_after_value(changes, attribute: 'etymology') + + assert_equal 'coli, < E. coli >', before + assert_equal 'coli, & E. coli !', after + end + +end diff --git a/test/lib/text_diff_test.rb b/test/lib/text_diff_test.rb new file mode 100644 index 00000000..c31c04dc --- /dev/null +++ b/test/lib/text_diff_test.rb @@ -0,0 +1,39 @@ +require 'minitest/autorun' +require_relative '../../lib/text_diff' + +class TextDiffTest < Minitest::Test + def test_groups_nearby_edits_and_preserves_surrounding_text + assert_equal [ + [true, 'E. coli: ', 'E. coli: '], + [false, 'of', 'from'], + [true, ' the colon', ' the colon'] + ], TextDiff.chunks('E. coli: of the colon', 'E. coli: from the colon') + end + + def test_keeps_distant_edits_separate + assert_equal [ + [false, '<', '&'], + [true, ' E. coli ', ' E. coli '], + [false, '>', '!'] + ], TextDiff.chunks('< E. coli >', '& E. coli !') + end + + def test_represents_additions_and_removals_with_an_empty_side + assert_equal [[true, 'E. coli', 'E. coli'], [false, '', ' strain']], + TextDiff.chunks('E. coli', 'E. coli strain') + assert_equal [[true, 'E. coli', 'E. coli'], [false, ' strain', '']], + TextDiff.chunks('E. coli strain', 'E. coli') + end + + def test_handles_empty_and_unchanged_text + assert_equal [], TextDiff.chunks(nil, '') + assert_equal [[true, 'E. coli', 'E. coli']], TextDiff.chunks('E. coli', 'E. coli') + end + + def test_preserves_unicode_graphemes + assert_equal [[true, 'M', 'M'], [false, 'ü', 'ö'], [true, 'ller', 'ller']], + TextDiff.chunks('Müller', 'Möller') + assert_equal [[true, 'E. coli: ', 'E. coli: '], [false, "e\u0301", 'ö']], + TextDiff.chunks("E. coli: e\u0301", 'E. coli: ö') + end +end From 78641e6e128f1bcd46c63f3718269b7752ded372 Mon Sep 17 00:00:00 2001 From: vangberg Date: Fri, 2 Oct 2026 14:30:32 +0200 Subject: [PATCH 07/12] public history: include proposed_in_id --- app/models/name/public_history.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/name/public_history.rb b/app/models/name/public_history.rb index 09ba3392..00bb1040 100644 --- a/app/models/name/public_history.rb +++ b/app/models/name/public_history.rb @@ -3,7 +3,7 @@ class Name::PublicHistory KEYS = %w[ name rank syllabication status priority_date authority nomenclatural_status taxonomic_status proposal_kind corrigendum_from - type_material type_accession + type_material type_accession proposed_in_id ].concat(Name::ETYMOLOGY_COLUMNS).freeze def initialize(name) From 1731cbaf7e0e752b08d85271a063c6b1bdda4463 Mon Sep 17 00:00:00 2001 From: vangberg Date: Fri, 2 Oct 2026 14:34:03 +0200 Subject: [PATCH 08/12] Render publication associations as citations in public history --- app/helpers/names_helper.rb | 29 +++++++++++++---------- app/views/names/history.html.erb | 5 ++-- test/controllers/names/history_test.rb | 32 ++++++++++++++++++++++++++ test/helpers/names_helper_test.rb | 14 +++++++---- 4 files changed, 62 insertions(+), 18 deletions(-) diff --git a/app/helpers/names_helper.rb b/app/helpers/names_helper.rb index def28f36..6754b3a6 100644 --- a/app/helpers/names_helper.rb +++ b/app/helpers/names_helper.rb @@ -1,20 +1,25 @@ module NamesHelper - def history_before_value(changes, attribute:) - before = changes.first - if attribute == 'etymology' - before = history_etymology_parts(*changes).first - end + def history_values(changes, attribute:) + values = + case attribute + when 'etymology' + history_etymology_parts(*changes) + when 'proposed_in_id' + changes.map { |id| history_publication_reference(id) } + else + changes + end - safe_join(Array(before)).presence || '—' + values.map { |value| safe_join(Array(value)).presence || '—' } end - def history_after_value(changes, attribute:) - after = changes.last - if attribute == 'etymology' - after = history_etymology_parts(*changes).last + def history_publication_reference(publication_id) + publication = Publication.find_by(id: publication_id) if publication_id + if publication + link_to(publication.short_citation, publication) + elsif publication_id + "Publication ##{publication_id} (deleted)" end - - safe_join(Array(after)).presence || '—' end def history_etymology_parts(before, after) diff --git a/app/views/names/history.html.erb b/app/views/names/history.html.erb index e7def6e5..fd45adea 100644 --- a/app/views/names/history.html.erb +++ b/app/views/names/history.html.erb @@ -15,12 +15,13 @@
<% @history.changes_for(version).each do |attribute, changes| %> + <% before, after = history_values(changes, attribute: attribute) %>
<%= Name.human_attribute_name(attribute) %>: - Before: <%= history_before_value(changes, attribute: attribute) %> + Before: <%= before %> - After: <%= history_after_value(changes, attribute: attribute) %> + After: <%= after %>
<% end %> diff --git a/test/controllers/names/history_test.rb b/test/controllers/names/history_test.rb index 4fbfcad2..9e5fa414 100644 --- a/test/controllers/names/history_test.rb +++ b/test/controllers/names/history_test.rb @@ -28,4 +28,36 @@ class NamesHistoryTest < ActionDispatch::IntegrationTest assert_select 'h3', text: 'Insufficient permits' assert_select '.name-history-version', count: 0 end + + test 'renders proposed publication changes as citations with links' do + name = names(:escherichia_coli) + first = publications(:one) + second = publications(:two) + name.update!(proposed_in: first) + name.update!(proposed_in: second) + name.update!(proposed_in: nil) + + get history_name_path(name) + + assert_response :success + assert_select '.name-history-version', count: 3 + assert_select '.name-history-before a[href=?]', publication_path(first), text: first.short_citation + assert_select '.name-history-after a[href=?]', publication_path(first), text: first.short_citation + assert_select '.name-history-before a[href=?]', publication_path(second), text: second.short_citation + assert_select '.name-history-after a[href=?]', publication_path(second), text: second.short_citation + assert_select '.name-history-before', text: 'Before: —', count: 1 + assert_select '.name-history-after', text: 'After: —', count: 1 + end + + test 'renders a retained publication id when the publication no longer exists' do + name = names(:escherichia_coli) + publication = publications(:no_doi) + name.update!(proposed_in: publication) + publication.destroy! + + get history_name_path(name) + + assert_response :success + assert_select '.name-history-after', text: "After: Publication ##{publication.id} (deleted)" + end end diff --git a/test/helpers/names_helper_test.rb b/test/helpers/names_helper_test.rb index 924c4520..e8613289 100644 --- a/test/helpers/names_helper_test.rb +++ b/test/helpers/names_helper_test.rb @@ -6,8 +6,7 @@ class NamesHelperTest < ActionView::TestCase { name: 'E. coli', etymology_xx_description: 'of the colon' }, { name: 'E. coli', etymology_xx_description: 'from the colon' } ] - before = history_before_value(changes, attribute: 'etymology') - after = history_after_value(changes, attribute: 'etymology') + before, after = history_values(changes, attribute: 'etymology') assert_equal 'coli, of the colon', before assert_equal 'coli, from the colon', after @@ -18,11 +17,18 @@ class NamesHelperTest < ActionView::TestCase { name: 'E. coli', etymology_xx_description: '< E. coli >' }, { name: 'E. coli', etymology_xx_description: '& E. coli !' } ] - before = history_before_value(changes, attribute: 'etymology') - after = history_after_value(changes, attribute: 'etymology') + before, after = history_values(changes, attribute: 'etymology') assert_equal 'coli, < E. coli >', before assert_equal 'coli, & E. coli !', after end + test 'renders proposed publication changes as citations' do + publication = publications(:one) + citation = link_to(publication.short_citation, publication) + assert_equal [citation, '—'], + history_values([publication.id, nil], attribute: 'proposed_in_id') + assert_equal ['—', citation], + history_values([nil, publication.id], attribute: 'proposed_in_id') + end end From 0ea55ca64700c2f2ac7e1a2d63b73a153d6e5410 Mon Sep 17 00:00:00 2001 From: vangberg Date: Fri, 2 Oct 2026 14:43:28 +0200 Subject: [PATCH 09/12] Highlight string changes in public name history --- app/helpers/names_helper.rb | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/app/helpers/names_helper.rb b/app/helpers/names_helper.rb index 6754b3a6..bd15b72a 100644 --- a/app/helpers/names_helper.rb +++ b/app/helpers/names_helper.rb @@ -3,11 +3,11 @@ def history_values(changes, attribute:) values = case attribute when 'etymology' - history_etymology_parts(*changes) + history_diff_parts(*changes.map { |attributes| Name.new(attributes).full_etymology }) when 'proposed_in_id' changes.map { |id| history_publication_reference(id) } else - changes + changes.any? { |value| value.is_a?(String) } ? history_diff_parts(*changes) : changes end values.map { |value| safe_join(Array(value)).presence || '—' } @@ -22,10 +22,7 @@ def history_publication_reference(publication_id) end end - def history_etymology_parts(before, after) - before = Name.new(before).full_etymology - after = Name.new(after).full_etymology - + def history_diff_parts(before, after) chunks = TextDiff.chunks(before, after) before_parts = chunks.filter_map do |unchanged, text, _| From 415e755c5ec0f3471d877f71188e866fa17ba869 Mon Sep 17 00:00:00 2001 From: vangberg Date: Fri, 2 Oct 2026 14:46:09 +0200 Subject: [PATCH 10/12] Show status names in public name history --- app/helpers/names_helper.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/helpers/names_helper.rb b/app/helpers/names_helper.rb index bd15b72a..fba9f636 100644 --- a/app/helpers/names_helper.rb +++ b/app/helpers/names_helper.rb @@ -6,6 +6,8 @@ def history_values(changes, attribute:) history_diff_parts(*changes.map { |attributes| Name.new(attributes).full_etymology }) when 'proposed_in_id' changes.map { |id| history_publication_reference(id) } + when 'status' + changes.map { |code| Name.status_hash.dig(code, :name) || code } else changes.any? { |value| value.is_a?(String) } ? history_diff_parts(*changes) : changes end From 10e4299733f088b19959fc23a3f439fa1487b52a Mon Sep 17 00:00:00 2001 From: vangberg Date: Fri, 2 Oct 2026 14:52:01 +0200 Subject: [PATCH 11/12] Render public name history as a table --- app/assets/stylesheets/names.scss | 66 ++++++++------------------ app/views/names/history.html.erb | 51 ++++++++++++-------- test/controllers/names/history_test.rb | 6 +-- 3 files changed, 52 insertions(+), 71 deletions(-) diff --git a/app/assets/stylesheets/names.scss b/app/assets/stylesheets/names.scss index c88ec0d7..e0c46162 100644 --- a/app/assets/stylesheets/names.scss +++ b/app/assets/stylesheets/names.scss @@ -16,64 +16,36 @@ } } -.name-history-version { - display: grid; - grid-template-columns: 11rem minmax(0, 1fr); - gap: 1rem; - padding: 0.65rem 0.75rem; +.name-history-table { + table-layout: fixed; + min-width: 42rem; + + thead th:first-child { + width: 12rem; + } + + thead th:nth-child(2) { + width: 10rem; + } .name-history-date { color: #6c757d; font-size: 0.875rem; + font-weight: normal; white-space: nowrap; } -} -.name-history-change { - display: grid; - grid-template-columns: 10rem minmax(0, 1fr); - gap: 0.5rem; - padding: 0.3rem 0.5rem; - - &:nth-child(even) { + .name-history-version:nth-of-type(even) { background-color: #f7f8fa; } -} - -.name-history-values { - display: grid; - grid-template-columns: minmax(0, 1fr) 2rem minmax(0, 1fr); - align-items: center; - min-width: 0; - overflow-wrap: anywhere; -} -.name-history-before, -.name-history-after { - white-space: pre-wrap; -} - -.name-history-arrow { - color: #6c757d; - text-align: center; -} - -@media (max-width: 767px) { - .name-history-version { - display: block; - - .name-history-date { - display: block; - margin-bottom: 0.2rem; - } + .name-history-version + .name-history-version { + border-top: 0.75rem solid var(--white); } - .name-history-change { - display: block; - - > strong { - display: block; - margin-bottom: 0.15rem; - } + .name-history-before, + .name-history-after { + white-space: pre-wrap; + overflow-wrap: anywhere; } } diff --git a/app/views/names/history.html.erb b/app/views/names/history.html.erb index fd45adea..23020cb6 100644 --- a/app/views/names/history.html.erb +++ b/app/views/names/history.html.erb @@ -6,28 +6,37 @@ <% if @versions.empty? %>

No history has been recorded for this name.

<% else %> -
- <% @versions.each do |version| %> -
- - -
- <% @history.changes_for(version).each do |attribute, changes| %> - <% before, after = history_values(changes, attribute: attribute) %> -
- <%= Name.human_attribute_name(attribute) %>: - - Before: <%= before %> - - After: <%= after %> - -
+
+ + + + + + + + + + <% @versions.each do |version| %> + <% changes = @history.changes_for(version) %> + + <% changes.each_with_index do |(attribute, values), index| %> + <% before, after = history_values(values, attribute: attribute) %> + + <% if index.zero? %> + + <% end %> + + + + <% end %> - - - <% end %> + + <% end %> +
DateAttributeBeforeAfter
+ + <%= Name.human_attribute_name(attribute) %><%= before %><%= after %>
<% end %> diff --git a/test/controllers/names/history_test.rb b/test/controllers/names/history_test.rb index 9e5fa414..f2a81998 100644 --- a/test/controllers/names/history_test.rb +++ b/test/controllers/names/history_test.rb @@ -45,8 +45,8 @@ class NamesHistoryTest < ActionDispatch::IntegrationTest assert_select '.name-history-after a[href=?]', publication_path(first), text: first.short_citation assert_select '.name-history-before a[href=?]', publication_path(second), text: second.short_citation assert_select '.name-history-after a[href=?]', publication_path(second), text: second.short_citation - assert_select '.name-history-before', text: 'Before: —', count: 1 - assert_select '.name-history-after', text: 'After: —', count: 1 + assert_select '.name-history-before', text: '—', count: 1 + assert_select '.name-history-after', text: '—', count: 1 end test 'renders a retained publication id when the publication no longer exists' do @@ -58,6 +58,6 @@ class NamesHistoryTest < ActionDispatch::IntegrationTest get history_name_path(name) assert_response :success - assert_select '.name-history-after', text: "After: Publication ##{publication.id} (deleted)" + assert_select '.name-history-after', text: "Publication ##{publication.id} (deleted)" end end From b50742627cf57fa9bb8652f9e332abeb71d91ec1 Mon Sep 17 00:00:00 2001 From: vangberg Date: Wed, 7 Oct 2026 17:01:08 +0200 Subject: [PATCH 12/12] Test public name history at the model layer --- test/controllers/names/history_test.rb | 45 +++---------------------- test/helpers/names_helper_test.rb | 7 ++++ test/models/name/public_history_test.rb | 31 +++++++++++++++++ 3 files changed, 42 insertions(+), 41 deletions(-) create mode 100644 test/models/name/public_history_test.rb diff --git a/test/controllers/names/history_test.rb b/test/controllers/names/history_test.rb index f2a81998..a8949e5b 100644 --- a/test/controllers/names/history_test.rb +++ b/test/controllers/names/history_test.rb @@ -1,7 +1,7 @@ require 'test_helper' class NamesHistoryTest < ActionDispatch::IntegrationTest - test 'shows public name history' do + test 'renders public history' do name = names(:escherichia_coli) name.update!(authority: 'Smith', etymology_xx_description: 'of the colon') @@ -12,15 +12,10 @@ class NamesHistoryTest < ActionDispatch::IntegrationTest assert_select '.name-history-change', count: 2 end - test 'shows an empty state without recorded history' do - get history_name_path(names(:escherichia_coli)) - - assert_response :success - assert_select 'p', text: 'No history has been recorded for this name.' - end - test 'does not reveal private name history' do - name = names(:draft_by_contributor) + name = Name.create!( + name: 'E. coli', rank: 'species', status: 5, created_by: users(:contributor) + ) name.update!(syllabication: 'co.li') get history_name_path(name) @@ -28,36 +23,4 @@ class NamesHistoryTest < ActionDispatch::IntegrationTest assert_select 'h3', text: 'Insufficient permits' assert_select '.name-history-version', count: 0 end - - test 'renders proposed publication changes as citations with links' do - name = names(:escherichia_coli) - first = publications(:one) - second = publications(:two) - name.update!(proposed_in: first) - name.update!(proposed_in: second) - name.update!(proposed_in: nil) - - get history_name_path(name) - - assert_response :success - assert_select '.name-history-version', count: 3 - assert_select '.name-history-before a[href=?]', publication_path(first), text: first.short_citation - assert_select '.name-history-after a[href=?]', publication_path(first), text: first.short_citation - assert_select '.name-history-before a[href=?]', publication_path(second), text: second.short_citation - assert_select '.name-history-after a[href=?]', publication_path(second), text: second.short_citation - assert_select '.name-history-before', text: '—', count: 1 - assert_select '.name-history-after', text: '—', count: 1 - end - - test 'renders a retained publication id when the publication no longer exists' do - name = names(:escherichia_coli) - publication = publications(:no_doi) - name.update!(proposed_in: publication) - publication.destroy! - - get history_name_path(name) - - assert_response :success - assert_select '.name-history-after', text: "Publication ##{publication.id} (deleted)" - end end diff --git a/test/helpers/names_helper_test.rb b/test/helpers/names_helper_test.rb index e8613289..699ab95a 100644 --- a/test/helpers/names_helper_test.rb +++ b/test/helpers/names_helper_test.rb @@ -31,4 +31,11 @@ class NamesHelperTest < ActionView::TestCase assert_equal ['—', citation], history_values([nil, publication.id], attribute: 'proposed_in_id') end + test 'renders a retained publication id when the publication no longer exists' do + publication = publications(:no_doi) + publication.destroy! + + assert_equal ['—', "Publication ##{publication.id} (deleted)"], + history_values([nil, publication.id], attribute: 'proposed_in_id') + end end diff --git a/test/models/name/public_history_test.rb b/test/models/name/public_history_test.rb new file mode 100644 index 00000000..544948f4 --- /dev/null +++ b/test/models/name/public_history_test.rb @@ -0,0 +1,31 @@ +require 'test_helper' + +class Name::PublicHistoryTest < ActiveSupport::TestCase + setup do + @name = names(:escherichia_coli) + @history = Name::PublicHistory.new(@name) + end + + test 'keeps public changes and groups etymology snapshots without unchanged companions' do + version = Version.new(record_type: 'Name', changeset: { + 'name' => ['E. coli', 'E. coli'], + 'authority' => [nil, 'Smith'], + 'proposed_in_id' => [nil, publications(:one).id], + 'gtdb_accession' => [nil, 'GB_GCA_000005845.2'], + 'etymology_xx_lang' => ['N.L.', 'N.L.'], + 'etymology_xx_description' => ['of the colon', 'from the colon'] + }) + + assert_equal({ + 'authority' => [nil, 'Smith'], + 'proposed_in_id' => [nil, publications(:one).id], + 'etymology' => [ + { 'name' => 'E. coli', 'etymology_xx_lang' => 'N.L.', + 'etymology_xx_description' => 'of the colon' }, + { 'name' => 'E. coli', 'etymology_xx_lang' => 'N.L.', + 'etymology_xx_description' => 'from the colon' } + ] + }, @history.changes_for(version)) + end + +end