Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
6ec7cd7
Add minimal Versioned concern and enable it for Name
vangberg Sep 17, 2026
1d3878d
Record related Name attributes together
vangberg Sep 17, 2026
9afc744
Add public name history view
vangberg Sep 17, 2026
53e736a
Refactor name history view into partials
vangberg Sep 25, 2026
5d53d81
Revert "Refactor name history view into partials"
vangberg Oct 2, 2026
1862f5e
Group nearby character edits in name history diffs
vangberg Oct 2, 2026
78641e6
public history: include proposed_in_id
vangberg Oct 2, 2026
1731cba
Render publication associations as citations in public history
vangberg Oct 2, 2026
0ea55ca
Highlight string changes in public name history
vangberg Oct 2, 2026
415e755
Show status names in public name history
vangberg Oct 2, 2026
10e4299
Render public name history as a table
vangberg Oct 2, 2026
b507426
Test public name history at the model layer
vangberg Oct 7, 2026
d80ea8d
Track history for PublicationName
vangberg Oct 2, 2026
c43a874
Simplify publication history changes with guard clauses
vangberg Oct 8, 2026
35c7621
Merge main into name history
vangberg Oct 20, 2026
77155f2
Snapshot all attributes in create and delete versions
vangberg Oct 20, 2026
865123f
Rename Unlinkable concern to Unlinking
vangberg Oct 20, 2026
1f792d7
Record polymorphic relationship IDs and types together in versions
vangberg Oct 20, 2026
918d2c9
Add Publication#display using the short citation
vangberg Oct 20, 2026
4944769
Show relationships, types and identifiers in name history
vangberg Oct 20, 2026
d0cdbc0
Group and resolve polymorphic references in name history generically
vangberg Oct 20, 2026
2eee6cb
Move name history into Names::HistoriesController
vangberg Oct 20, 2026
9b02b0c
Drop legacy type columns from public name history
vangberg Oct 20, 2026
77eba84
Allowlist publication link attributes in public name history
vangberg Oct 20, 2026
39ca455
Unlink placements instead of deleting them
vangberg Oct 20, 2026
cfab274
Add PublicationName#display using the publication
vangberg Oct 20, 2026
7d05fa8
Track placement history and show it in public name history
vangberg Oct 20, 2026
1f881c1
Version rich text records and show Name rich text in public history
vangberg Oct 20, 2026
48fd721
Version rich text records and show Name rich text diffs in public his…
vangberg Oct 20, 2026
6ad0a6a
Record the user on versions and show them in public name history
vangberg Oct 20, 2026
e93ca3b
Contain soft deletion of name relationships in their models
vangberg Oct 29, 2026
51980b3
Use neutral placement headings in name history
vangberg Oct 29, 2026
00a8b3a
Enforce one active preferred placement per name
vangberg Oct 29, 2026
00a0c97
Fix taxonomy imports after soft deletion scope change
vangberg Oct 29, 2026
8e9b940
Remove low-value tests from name history work
vangberg Oct 29, 2026
f5f70a3
Rename history_diff_parts to history_text_diff
vangberg Oct 29, 2026
f2cf985
Drop genome_id from public name history
vangberg Oct 29, 2026
680c941
Simplify public name history link and nomenclatural type changes
vangberg Oct 29, 2026
c2c8b85
Render polymorphic history references by reflection again
vangberg Oct 29, 2026
e6925f5
Merge name history migrations
vangberg Oct 29, 2026
add90bd
Merge main into name history
vangberg Oct 29, 2026
375ba15
clean up tests
vangberg Oct 29, 2026
126aa60
Remove name history compatibility code for unreleased version formats
vangberg Oct 29, 2026
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.7)
erubi (1.13.1)
Expand Down Expand Up @@ -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)
Expand Down
52 changes: 52 additions & 0 deletions app/assets/stylesheets/names.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
1 change: 1 addition & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down
17 changes: 17 additions & 0 deletions app/controllers/names/histories_controller.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion app/controllers/placements_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/publication_names_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
124 changes: 124 additions & 0 deletions app/helpers/names/histories_helper.rb
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions app/models/concerns/soft_deletable.rb
Original file line number Diff line number Diff line change
@@ -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
86 changes: 86 additions & 0 deletions app/models/concerns/versioned.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions app/models/current.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
class Current < ActiveSupport::CurrentAttributes
# The user whose request is being handled. Versions record it as their author.
attribute :user
end
Loading
Loading