diff --git a/app/controllers/tag_versions_controller.rb b/app/controllers/tag_versions_controller.rb
new file mode 100644
index 000000000..e822f2d95
--- /dev/null
+++ b/app/controllers/tag_versions_controller.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class TagVersionsController < ApplicationController
+ respond_to :html, :xml, :json
+
+ def index
+ tag_id = params[:tag_id] || params[:search][:tag_id]
+ tag_name = params[:search][:name_matches]
+ updater_id = params[:updater_id] || params[:search][:updater_id]
+ updater_name = params[:search][:updater_name]
+
+ @tag = Tag.find(tag_id) if tag_id
+ @tag = Tag.find_by_name(tag_name) if tag_name
+ @updater = User.find(updater_id) if updater_id
+ @updater = User.find_by_name(updater_name) if updater_name
+
+ if request.format.html?
+ @tag_versions = authorize TagVersion.visible(CurrentUser.user).paginated_search(params, defaults: { tag_id: @tag&.id, updater_id: @updater&.id, order: "updated_at" }, count_pages: true)
+ @tag_versions = @tag_versions.includes(:tag, :updater, :previous_version)
+ else
+ @tag_versions = authorize TagVersion.visible(CurrentUser.user).paginated_search(params, defaults: { tag_id: @tag&.id, updater_id: @updater&.id })
+ end
+
+ respond_with(@tag_versions)
+ end
+
+ def show
+ @tag_version = authorize TagVersion.find(params[:id])
+
+ respond_with(@tag_version) do |format|
+ format.html { redirect_to tag_versions_path(search: { id: @tag_version.id }) }
+ end
+ end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index c27d2a4c2..8b490a17c 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -198,8 +198,8 @@ module ApplicationHelper
link_to ip_addr.to_s, ip_addresses_path(search: { ip_addr: ip, group_by: "user" }), **options
end
- def link_to_search(search)
- link_to search, posts_path(tags: search)
+ def link_to_search(tag, **options)
+ link_to tag.name, posts_path(tags: tag.name), class: tag_class(tag), **options
end
def link_to_wiki(text, title = text, **options)
diff --git a/app/logical/concerns/version_for.rb b/app/logical/concerns/version_for.rb
index 7e7ba692c..0a8896bbb 100644
--- a/app/logical/concerns/version_for.rb
+++ b/app/logical/concerns/version_for.rb
@@ -78,6 +78,11 @@ module VersionFor
previous_version.nil?
end
+ # True if this version was updated after it was created (it was a merged edit).
+ def revised?
+ updated_at > created_at
+ end
+
# Return a hash of changes made by this edit (compared to the previous version, or to another version).
#
# The hash looks like `{ attr => [old_value, new_value] }`.
diff --git a/app/models/tag_version.rb b/app/models/tag_version.rb
index b566ee2d7..fbbbba6f1 100644
--- a/app/models/tag_version.rb
+++ b/app/models/tag_version.rb
@@ -4,4 +4,39 @@ class TagVersion < ApplicationRecord
include VersionFor
version_for :tag
+
+ def self.name_matches(name)
+ where_like(:name, Tag.normalize_name(name))
+ end
+
+ def self.search(params)
+ q = search_attributes(params, :id, :created_at, :updated_at, :version, :name, :category, :is_deprecated, :tag, :updater, :previous_version)
+
+ if params[:name_matches].present?
+ q = q.name_matches(params[:name_matches])
+ end
+
+ case params[:order]
+ when "created_at", "created_at_desc"
+ q = q.order(created_at: :desc, id: :desc)
+ when "created_at_asc"
+ q = q.order(created_at: :asc, id: :asc)
+ when "updated_at", "updated_at_desc"
+ q = q.order(updated_at: :desc, id: :desc)
+ when "updated_at_asc"
+ q = q.order(updated_at: :asc, id: :asc)
+ when "id", "id_desc"
+ q = q.order(id: :desc)
+ when "id_asc"
+ q = q.order(id: :asc)
+ else
+ q = q.apply_default_order(params)
+ end
+
+ q
+ end
+
+ def category_name
+ TagCategory.reverse_mapping[category].capitalize
+ end
end
diff --git a/app/policies/tag_version_policy.rb b/app/policies/tag_version_policy.rb
new file mode 100644
index 000000000..646c24245
--- /dev/null
+++ b/app/policies/tag_version_policy.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+class TagVersionPolicy < ApplicationPolicy
+end
diff --git a/app/views/static/site_map.html.erb b/app/views/static/site_map.html.erb
index 24cd35eb4..a3f798f4b 100644
--- a/app/views/static/site_map.html.erb
+++ b/app/views/static/site_map.html.erb
@@ -48,9 +48,10 @@
Tags
<%= link_to_wiki "Help", "help:tags" %>
<%= link_to_wiki "Cheat sheet", "help:cheatsheet" %>
+ <%= link_to("Listing", tags_path) %>
+ <%= link_to("History", tag_versions_path) %>
<%= link_to("Aliases", tag_aliases_path) %>
<%= link_to("Implications", tag_implications_path) %>
- <%= link_to("Listing", tags_path) %>
<%= link_to("AI Tags", ai_tags_path) %>
<%= link_to("Related Tags", related_tag_path) %>
diff --git a/app/views/tag_versions/index.html.erb b/app/views/tag_versions/index.html.erb
new file mode 100644
index 000000000..b97d47654
--- /dev/null
+++ b/app/views/tag_versions/index.html.erb
@@ -0,0 +1,79 @@
+
+
+ <% if @tag %>
+
Tag History: <%= link_to_search @tag %>
+ <%= link_to "« Back", tag_versions_path, class: "text-xs" %>
+ <% elsif @updater %>
+
Tag History: <%= link_to_user @updater %>
+ <%= link_to "« Back", tag_versions_path, class: "text-xs" %>
+ <% else %>
+
Tag History
+ <% end %>
+
+ <%= search_form_for(tag_versions_path) do |f| %>
+ <%= f.input :name_matches, label: "Tag", input_html: { value: @tag&.name, "data-autocomplete": "tag" } %>
+ <%= f.input :updater_name, label: "User", input_html: { value: @updater&.name, "data-autocomplete": "user" } %>
+ <%= f.input :order, collection: [%w[Newest updated_at], %w[Oldest updated_at_asc]], include_blank: true, selected: params[:search][:order] %>
+ <%= f.submit "Search" %>
+ <% end %>
+
+
+ <%= table_for @tag_versions, class: "striped autofit", width: "100%" do |t| %>
+ <% t.column "Tag" do |tag_version| %>
+
+ <%= link_to_wiki "?", tag_version.tag.name %>
+ <%= link_to tag_version.tag.name, posts_path(tags: tag_version.tag.name) %>
+ <%= link_to "»", tag_versions_path(search: { tag_id: tag_version.tag_id }) %>
+
+ <% end %>
+
+ <% t.column "Change", td: { class: "col-expand" } do |tag_version| %>
+ <% if tag_version.first_version? && tag_version.created_at - tag_version.tag.created_at < 1.minute %>
+ Anonymous created <%= link_to_search tag_version %>.
+ <% elsif tag_version.first_version? %>
+ Anonymous updated <%= link_to_search tag_version %>.
+ <% else %>
+ <% if !tag_version.previous_version.is_deprecated? && tag_version.is_deprecated? %>
+ <%= link_to_user tag_version.updater %> deprecated <%= link_to_search tag_version %>.
+ <% elsif tag_version.previous_version.is_deprecated? && !tag_version.is_deprecated? %>
+ <%= link_to_user tag_version.updater %> undeprecated <%= link_to_search tag_version %>.
+ <% end %>
+
+ <% if tag_version.previous_version.category != tag_version.category %>
+ <%= link_to_user tag_version.updater %> changed <%= link_to_search tag_version %> from <%= tag_version.previous_version.category_name.downcase %> to <%= tag_version.category_name.downcase %>.
+ <% end %>
+ <% end %>
+ <% end %>
+
+ <% t.column "Date" do |tag_version| %>
+ <%= time_ago_in_words_tagged(tag_version.updated_at) %>
+ <% end %>
+
+ <% t.column "User" do |tag_version| %>
+ <%= link_to_user tag_version.updater %>
+ <%= link_to "»", tag_versions_path(search: { **search_params, updater_id: tag_version.updater_id }) %>
+ <% end %>
+
+ <% t.column column: "control" do |tag_version| %>
+ <%= render PopupMenuComponent.new do |menu| %>
+ <% if policy(tag_version.tag).update? %>
+ <% menu.item do %>
+ <%= link_to "Edit tag", edit_tag_path(tag_version.tag) %>
+ <% end %>
+ <% end %>
+
+ <% unless @tag %>
+ <% menu.item do %>
+ <%= link_to "Tag history", tag_versions_path(search: { tag_id: tag_version.tag_id }) %>
+ <% end %>
+ <% end %>
+ <% end %>
+ <% end %>
+ <% end %>
+
+
+ <%= numbered_paginator(@tag_versions) %>
+
+
+
+<%= render "tags/secondary_links" %>
diff --git a/app/views/tags/_secondary_links.html.erb b/app/views/tags/_secondary_links.html.erb
index d28e477be..8b5d3f262 100644
--- a/app/views/tags/_secondary_links.html.erb
+++ b/app/views/tags/_secondary_links.html.erb
@@ -1,6 +1,7 @@
<% content_for(:secondary_links) do %>
<%= quick_search_form_for(:name_matches, tags_path, "tags", autocomplete: "tag") %>
<%= subnav_link_to "Tags", tags_path %>
+ <%= subnav_link_to "History", tag_versions_path %>
<%= subnav_link_to("Aliases", tag_aliases_path) %>
<%= subnav_link_to("Implications", tag_implications_path) %>
<%= subnav_link_to "Request alias/implication", new_bulk_update_request_path %>
diff --git a/app/views/wiki_pages/_sidebar.html.erb b/app/views/wiki_pages/_sidebar.html.erb
index 11c2035f0..f846b6300 100644
--- a/app/views/wiki_pages/_sidebar.html.erb
+++ b/app/views/wiki_pages/_sidebar.html.erb
@@ -7,7 +7,8 @@
<% unless @wiki_page.is_meta_wiki? %>
- - <%= link_to "Tag History", post_versions_path(search: { changed_tags: @wiki_page.title }) %>
+ - <%= link_to "Tag History", tag_versions_path(search: { tag_id: @wiki_page.tag.id }) %>
+ - <%= link_to "Post History", post_versions_path(search: { changed_tags: @wiki_page.title }) %>
<% end %>
- <%= link_to "Wiki History", wiki_page_versions_path(search: { wiki_page_id: @wiki_page.id }) %>
<% if Danbooru.config.forum_enabled?.to_s.truthy? %>
diff --git a/config/routes.rb b/config/routes.rb
index fb9990282..8ddb8fa69 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -251,6 +251,7 @@ Rails.application.routes.draw do
resources :tags
resources :tag_aliases, only: [:show, :index, :destroy]
resources :tag_implications, only: [:show, :index, :destroy]
+ resources :tag_versions, only: [:index, :show]
get "/redeem", to: "upgrade_codes#redeem", as: "redeem_upgrade_codes"
resources :upgrade_codes, only: [:create, :index] do
diff --git a/test/functional/tag_versions_controller_test.rb b/test/functional/tag_versions_controller_test.rb
new file mode 100644
index 000000000..2fdd6d390
--- /dev/null
+++ b/test/functional/tag_versions_controller_test.rb
@@ -0,0 +1,53 @@
+require 'test_helper'
+
+class TagVersionsControllerTest < ActionDispatch::IntegrationTest
+ context "The tag versions controller" do
+ context "index action" do
+ setup do
+ @user = create(:user)
+ @tag = create(:tag, name: "test", created_at: 6.months.ago, updated_at: 3.months.ago)
+ travel_to(4.hours.ago) { @tag.update!(category: Tag.categories.character, updater: @user) }
+ travel_to(3.hours.ago) { @tag.update!(is_deprecated: true, updater: @user) }
+ travel_to(2.hours.ago) { @tag.update!(is_deprecated: false, updater: @user) }
+ end
+
+ should "render" do
+ get tag_versions_path
+ assert_response :success
+ end
+
+ should "render for a tag" do
+ get tag_versions_path(search: { name_matches: @tag.name })
+ assert_response :success
+ end
+
+ should "render for a user" do
+ get tag_versions_path(search: { updater_id: @user.id })
+ assert_response :success
+ end
+
+ should "render for a json response" do
+ get tag_versions_path, as: :json
+ assert_response :success
+ end
+ end
+
+ context "show action" do
+ setup do
+ @user = create(:user)
+ @tag = create(:tag)
+ @tag.update!(category: Tag.categories.character, updater: @user)
+ end
+
+ should "render" do
+ get tag_version_path(@tag.versions.last)
+ assert_redirected_to tag_versions_path(search: { id: @tag.versions.last.id })
+ end
+
+ should "render for a json response" do
+ get tag_version_path(@tag.versions.last), as: :json
+ assert_response :success
+ end
+ end
+ end
+end