Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions app/assets/stylesheets/names.scss
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,36 @@
}
}

.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-version:nth-of-type(even) {
background-color: #f7f8fa;
}

.name-history-version + .name-history-version {
border-top: 0.75rem solid var(--white);
}

.name-history-before,
.name-history-after {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
}
9 changes: 8 additions & 1 deletion app/controllers/names_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions app/helpers/names_helper.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,46 @@
module NamesHelper
def history_values(changes, attribute:)
values =
case attribute
when 'etymology'
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

values.map { |value| safe_join(Array(value)).presence || '—' }
end

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
end

def history_diff_parts(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

def link_to_name_type(name)
if name.type_is_name?
if name.type_name
Expand Down Expand Up @@ -64,4 +106,7 @@ def name_lineage(name, links: true, last: true, register: nil, visited: [])

out.inject(:+)
end

private

end
62 changes: 62 additions & 0 deletions app/models/concerns/versioned.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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 record_create_version
versions.create!(operation: 'create', changeset: {})
end

def record_update_version
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
4 changes: 4 additions & 0 deletions app/models/name.rb
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,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
Expand Down
52 changes: 52 additions & 0 deletions app/models/name/public_history.rb
Original file line number Diff line number Diff line change
@@ -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 proposed_in_id
].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
3 changes: 3 additions & 0 deletions app/models/version.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
class Version < ApplicationRecord
belongs_to :record, polymorphic: true, optional: true
end
2 changes: 1 addition & 1 deletion app/views/names/_title.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -78,4 +79,3 @@
</div>
<% end %>
<hr/>

43 changes: 43 additions & 0 deletions app/views/names/history.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<% provide(:title, "History of #{@name.abbr_name_raw}") %>

<h1>History of <%= @name.name_html %></h1>
<p><%= link_to('Back to name', @name) %></p>

<% if @versions.empty? %>
<p class="text-muted">No history has been recorded for this name.</p>
<% else %>
<div class="table-responsive">
<table class="table table-sm name-history-table">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Attribute</th>
<th scope="col">Before</th>
<th scope="col">After</th>
</tr>
</thead>
<% @versions.each do |version| %>
<% changes = @history.changes_for(version) %>
<tbody class="name-history-version" data-version-id="<%= version.id %>">
<% changes.each_with_index do |(attribute, values), index| %>
<% before, after = history_values(values, attribute: attribute) %>
<tr class="name-history-change">
<% if index.zero? %>
<th class="name-history-date" scope="rowgroup" rowspan="<%= changes.size %>">
<time datetime="<%= version.created_at.iso8601 %>">
<%= version.created_at.strftime('%F %H:%M %Z') %>
</time>
</th>
<% end %>
<th scope="row"><%= Name.human_attribute_name(attribute) %></th>
<td class="name-history-before"><%= before %></td>
<td class="name-history-after"><%= after %></td>
</tr>
<% end %>
</tbody>
<% end %>
</table>
</div>
<% end %>

<%= will_paginate(@versions) %>
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
end
member do
# --> Display name
get :history
get :network
get :linkout
get :wiki
Expand Down
10 changes: 10 additions & 0 deletions db/migrate/20260917100000_create_versions.rb
Original file line number Diff line number Diff line change
@@ -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
11 changes: 10 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions lib/text_diff.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading