From bec983fdf1b080733710fd91ffc7886af868efff Mon Sep 17 00:00:00 2001 From: "byte[]" Date: Sat, 30 Nov 2019 23:51:44 -0500 Subject: [PATCH] duplicate reports --- .../duplicate_report_controller.ex | 57 +++++++ lib/philomena_web/router.ex | 1 + .../duplicate_report/_image_cell.html.slime | 29 ++++ .../duplicate_report/_list.html.slime | 155 ++++++++++++++++++ .../duplicate_report/index.html.slime | 24 +++ .../duplicate_report/show.html.slime | 49 ++++++ .../views/duplicate_report_view.ex | 135 +++++++++++++++ .../duplicate_report_controller_test.exs | 88 ++++++++++ 8 files changed, 538 insertions(+) create mode 100644 lib/philomena_web/controllers/duplicate_report_controller.ex create mode 100644 lib/philomena_web/templates/duplicate_report/_image_cell.html.slime create mode 100644 lib/philomena_web/templates/duplicate_report/_list.html.slime create mode 100644 lib/philomena_web/templates/duplicate_report/index.html.slime create mode 100644 lib/philomena_web/templates/duplicate_report/show.html.slime create mode 100644 lib/philomena_web/views/duplicate_report_view.ex create mode 100644 test/philomena_web/controllers/duplicate_report_controller_test.exs diff --git a/lib/philomena_web/controllers/duplicate_report_controller.ex b/lib/philomena_web/controllers/duplicate_report_controller.ex new file mode 100644 index 00000000..43c5270f --- /dev/null +++ b/lib/philomena_web/controllers/duplicate_report_controller.ex @@ -0,0 +1,57 @@ +defmodule PhilomenaWeb.DuplicateReportController do + use PhilomenaWeb, :controller + + alias Philomena.DuplicateReports + alias Philomena.DuplicateReports.DuplicateReport + alias Philomena.Images.Image + alias Philomena.Repo + import Ecto.Query + + @valid_states ~W(open rejected accepted claimed) + + plug PhilomenaWeb.FilterBannedUsersPlug when action in [:create] + plug :load_resource, model: DuplicateReport, only: [:show], preload: [:user, image: :tags, duplicate_of_image: :tags] + + def index(conn, params) do + states = + params["states"] + |> wrap() + |> Enum.filter(&Enum.member?(@valid_states, &1)) + + duplicate_reports = + DuplicateReport + |> where([d], d.state in ^states) + |> preload([:user, image: :tags, duplicate_of_image: :tags]) + |> order_by(desc: :created_at) + |> Repo.paginate(conn.assigns.pagination) + + render(conn, "index.html", duplicate_reports: duplicate_reports, layout_class: "layout--wide") + end + + def create(conn, %{"duplicate_report" => duplicate_report_params}) do + attribution = conn.assigns.attribution + source = Repo.get!(Image, duplicate_report_params["image_id"]) + target = Repo.get!(Image, duplicate_report_params["duplicate_of_image_id"]) + + case DuplicateReports.create_duplicate_report(source, target, attribution, duplicate_report_params) do + {:ok, duplicate_report} -> + conn + |> put_flash(:info, "Duplicate report created successfully.") + |> redirect(to: Routes.image_path(conn, :show, duplicate_report.image_id)) + + {:error, _changeset} -> + conn + |> put_flash(:error, "Failed to submit duplicate report") + |> redirect(external: conn.assigns.referrer) + end + end + + def show(conn, _params) do + dr = conn.assigns.duplicate_report + + render(conn, "show.html", duplicate_report: dr, layout_class: "layout--wide") + end + + defp wrap(list) when is_list(list), do: list + defp wrap(not_a_list), do: [not_a_list] +end diff --git a/lib/philomena_web/router.ex b/lib/philomena_web/router.ex index 5216e087..95664e59 100644 --- a/lib/philomena_web/router.ex +++ b/lib/philomena_web/router.ex @@ -155,6 +155,7 @@ defmodule PhilomenaWeb.Router do resources "/stats", StatController, only: [:index] resources "/channels", ChannelController, only: [:index, :show] resources "/settings", SettingController, only: [:edit, :update], singleton: true + resources "/duplicate_reports", DuplicateReportController, only: [:index, :show] get "/:id", ImageController, :show # get "/:forum_id", ForumController, :show # impossible to do without constraints diff --git a/lib/philomena_web/templates/duplicate_report/_image_cell.html.slime b/lib/philomena_web/templates/duplicate_report/_image_cell.html.slime new file mode 100644 index 00000000..1937550e --- /dev/null +++ b/lib/philomena_web/templates/duplicate_report/_image_cell.html.slime @@ -0,0 +1,29 @@ +.grid--dupe-report-list__cell.flex.flex--column.flex--spaced-out.flex--centered.flex--no-wrap.center.dr__image-cell.border-vertical + p + - if is_nil(@image) do + | (Image now hard-deleted) + - else + | # + = @image.id + + = render PhilomenaWeb.ImageView, "_image_container.html", image: @image, size: @thumb_small, conn: @conn + + p + = @image.image_width + | x + = @image.image_height + + p + = render PhilomenaWeb.UserAttributionView, "_anon_user.html", object: @image, conn: @conn + + /- if report.valid? && can_manage_dr + - if source + a href=duplicate_report_accept_reverse_path(report) data-method="post" + button.button + ' Keep Source + i.fa.fa-arrow-left + - else + a href=duplicate_report_accept_path(report) data-method="post" + button.button + i.fa.fa-arrow-right> + | Keep Target diff --git a/lib/philomena_web/templates/duplicate_report/_list.html.slime b/lib/philomena_web/templates/duplicate_report/_list.html.slime new file mode 100644 index 00000000..1843789a --- /dev/null +++ b/lib/philomena_web/templates/duplicate_report/_list.html.slime @@ -0,0 +1,155 @@ +.grid.grid--dupe-report-list + p Source image + p Target image + p Diff + p Status/options + + = for report <- @duplicate_reports do + - background_class = background_class(report) + + = render PhilomenaWeb.DuplicateReportView, "_image_cell.html", image: report.image, source: true, report: report, conn: @conn + = render PhilomenaWeb.DuplicateReportView, "_image_cell.html", image: report.duplicate_of_image, source: false, report: report, conn: @conn + + .grid--dupe-report-list__cell.dr__diff.border-vertical + table.table + tr + = if same_aspect_ratio?(report) do + td.success + a href=Routes.duplicate_report_path(@conn, :show, report) + ' Visual diff + | (Same aspect ratio) + + - else + td.warning Different aspect ratio + tr + = cond do + - both_are_edits?(report) -> + td.warning Both are edits + + - target_is_edit?(report) -> + td.danger Target is an edit + + - source_is_edit?(report) -> + td.danger Source is an edit + + - true -> + td.success Neither is an edit + + tr + = cond do + - both_are_alternate_versions?(report) -> + td.warning Both are alternate versions + + - target_is_alternate_version?(report) -> + td.danger Target is an alternate version + + - source_is_alternate_version?(report) -> + td.danger Source is an alternate version + + - true -> + td.success Neither is an alternate version + + tr + = cond do + - same_res?(report) -> + td.sucecss Same resolution + + - higher_res?(report) -> + td.warning Target resolution better + + - true -> + td.warning Source resolution better + + tr + = cond do + - same_format?(report) -> + td.success + ' Same format + = file_types(report) + + - better_format?(report) -> + td.warning + ' Target format better + = file_types(report) + + - true -> + td.warning + ' Source format better + = file_types(report) + + tr + = cond do + - same_artist_tags?(report) -> + td.success Same artist tags + + - more_artist_tags_on_target?(report) -> + td.warning More artist tags on target + + - more_artist_tags_on_source?(report) -> + td.warning More artist tags on source + + - true -> + td.danger Different artist tags + + tr + = cond do + - neither_have_source?(report) -> + td.warning Neither have sources + + - same_source?(report) -> + td.success Same sources + + - similar_source?(report) -> + td.warning Same hostname + + - source_on_target?(report) -> + td.warning Target has a source + + - source_on_source?(report) -> + td.warning Source has a source + + - true -> + td.danger Different sources + + tr + = if same_rating_tags?(report) do + td.success Same rating tags + - else + td.danger Different rating tags + + tr + = if forward_merge?(report) do + td.warning Target newer + - else + td.success Target older + + .flex.flex--column.grid--dupe-report-list__cell.border-vertical id="report_options_#{report.id}" + .dr__status-options class=background_class + = String.capitalize(report.state) + + /- if can_manage_dr && report.modifier.present? + ' by + = report.modifier.name + /- if can_manage_dr + div + - if report.state == 'open' + a href=duplicate_report_claim_path(report, target: "report_options_#{report.id}") data-method="post" + button.button.button--separate-right + i.fa.fa-clipboard> + | Claim + - if report.state != 'rejected' + a href=duplicate_report_reject_path(report) data-method="post" + button.button + i.fa.fa-times> + | Reject + + .dr__status-options + div + ' Reported + => pretty_time(report.created_at) + + = if report.user do + ' by + =< link report.user.name, to: Routes.profile_path(@conn, :show, report.user) + + = report.reason diff --git a/lib/philomena_web/templates/duplicate_report/index.html.slime b/lib/philomena_web/templates/duplicate_report/index.html.slime new file mode 100644 index 00000000..945ebd9b --- /dev/null +++ b/lib/philomena_web/templates/duplicate_report/index.html.slime @@ -0,0 +1,24 @@ +h1 Duplicate Reports + +- route = fn p -> Routes.duplicate_report_path(@conn, :index, p) end +- pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @duplicate_reports, route: route, conn: @conn + +.block + .block__header + = pagination + + span.block__header__title Display only: + => link "Open (All)", to: Routes.duplicate_report_path(@conn, :index, states: ~W(open claimed)) + => link "Open (Unclaimed)", to: Routes.duplicate_report_path(@conn, :index, states: ~W(open)) + => link "Open (Claimed)", to: Routes.duplicate_report_path(@conn, :index, states: ~W(claimed)) + => link "Open + Rejected", to: Routes.duplicate_report_path(@conn, :index, states: ~W(open rejected)) + => link "Rejected", to: Routes.duplicate_report_path(@conn, :index, states: ~W(rejected)) + => link "Rejected + Accepted", to: Routes.duplicate_report_path(@conn, :index, states: ~W(rejected accepted)) + => link "Accepted", to: Routes.duplicate_report_path(@conn, :index, states: ~W(accepted)) + = link "All", to: Routes.duplicate_report_path(@conn, :index, states: ~W(open rejected accepted claimed)) + + = render PhilomenaWeb.DuplicateReportView, "_list.html", duplicate_reports: @duplicate_reports, conn: @conn + + .block + .block__header.block__header--light + = pagination diff --git a/lib/philomena_web/templates/duplicate_report/show.html.slime b/lib/philomena_web/templates/duplicate_report/show.html.slime new file mode 100644 index 00000000..fd64d50f --- /dev/null +++ b/lib/philomena_web/templates/duplicate_report/show.html.slime @@ -0,0 +1,49 @@ +elixir: + source_url = comparison_url(@conn, @duplicate_report.image) + target_url = comparison_url(@conn, @duplicate_report.duplicate_of_image) + {width, height} = largest_dimensions([@duplicate_report.image, @duplicate_report.duplicate_of_image]) + +h1 Difference +.difference + svg.difference__image viewBox="0 0 #{width} #{height}" height=height + defs + filter#overlay-diff + feImage#source xlink:href=source_url result="source" width="100%" height="100%" x="0" y="0" + feImage#target xlink:href=target_url result="target" width="100%" height="100%" x="0" y="0" + feBlend in="source" in2="target" mode="difference" result="diff" + + / Contrast-boost matrix = (5I|0) [4x5] + feColorMatrix in="diff" type="matrix" values="5 0 0 0 0 0 5 0 0 0 0 0 5 0 0 0 0 0 5 0" + rect width=width height=height filter="url(#overlay-diff)" + +h1 Swipe +.swipe + svg.swipe__image viewBox="0 0 #{width} #{height}" height=height + defs + pattern#checkerboard width="16" height="16" patternUnits="userSpaceOnUse" + rect width="8" height="8" x="0" y="0" fill="#ffffff44" + rect width="8" height="8" x="0" y="8" fill="#00000044" + rect width="8" height="8" x="8" y="0" fill="#00000044" + rect width="8" height="8" x="8" y="8" fill="#ffffff44" + clipPath#clip + rect width=div(width, 2) height=height + rect width=width height=height fill="url(#checkerboard)" + image#target width="100%" height="100%" xlink:href=target_url + image#source width="100%" height="100%" xlink:href=source_url clip-path="url(#clip)" + rect#divider width="3" height=height x=div(width, 2) fill="#000" stroke="#fff" stroke-width="1" + +h1 Onion Skin +.onion-skin + svg.onion-skin__image viewBox="0 0 #{width} #{height}" height=height + defs + pattern#checkerboard width="16" height="16" patternUnits="userSpaceOnUse" + rect width="8" height="8" x="0" y="0" fill="#ffffff44" + rect width="8" height="8" x="0" y="8" fill="#00000044" + rect width="8" height="8" x="8" y="0" fill="#00000044" + rect width="8" height="8" x="8" y="8" fill="#ffffff44" + rect width=width height=height fill="url(#checkerboard)" + image#source width="100%" height="100%" xlink:href=source_url + image#target width="100%" height="100%" xlink:href=target_url + input.onion-skin__slider type="range" min="0" max="1" step="0.01" + +p Left is source, right is target \ No newline at end of file diff --git a/lib/philomena_web/views/duplicate_report_view.ex b/lib/philomena_web/views/duplicate_report_view.ex new file mode 100644 index 00000000..ea3af2cc --- /dev/null +++ b/lib/philomena_web/views/duplicate_report_view.ex @@ -0,0 +1,135 @@ +defmodule PhilomenaWeb.DuplicateReportView do + use PhilomenaWeb, :view + + alias PhilomenaWeb.ImageView + + @formats_order ~W(video/webm image/svg+xml image/png image/gif image/jpeg other) + + def comparison_url(conn, image), + do: ImageView.thumb_url(image, can?(conn, :show, image), :full) + + def largest_dimensions(images) do + images + |> Enum.map(&{&1.image_width, &1.image_height}) + |> Enum.max_by(fn {w, h} -> w * h end) + end + + def background_class(%{state: "rejected"}), do: "background-danger" + def background_class(%{state: "accepted"}), do: "background-success" + def background_class(%{state: "claimed"}), do: "background-warning" + def background_class(_duplicate_report), do: nil + + def file_types(%{image: image, duplicate_of_image: duplicate_of_image}) do + source_type = String.upcase(image.image_format) + target_type = String.upcase(duplicate_of_image.image_format) + + "(#{source_type}, #{target_type})" + end + + def forward_merge?(%{image_id: image_id, duplicate_of_image_id: duplicate_of_image_id}), + do: duplicate_of_image_id > image_id + + def higher_res?(%{image: image, duplicate_of_image: duplicate_of_image}), + do: duplicate_of_image.image_width > image.image_width or duplicate_of_image.image_height > image.image_height + + def same_res?(%{image: image, duplicate_of_image: duplicate_of_image}), + do: duplicate_of_image.image_width == image.image_width and duplicate_of_image.image_height == image.image_height + + def same_format?(%{image: image, duplicate_of_image: duplicate_of_image}), + do: duplicate_of_image.image_mime_type == image.image_mime_type + + def better_format?(%{image: image, duplicate_of_image: duplicate_of_image}) do + source_index = Enum.find_index(@formats_order, image.image_mime_type) || length(@formats_order) - 1 + target_index = Enum.find_index(@formats_order, duplicate_of_image.image_mime_type) || length(@formats_order) - 1 + + target_index < source_index + end + + def same_aspect_ratio?(%{image: image, duplicate_of_image: duplicate_of_image}), + do: abs(duplicate_of_image.image_aspect_ratio - image.image_aspect_ratio) <= 0.009 + + def neither_have_source?(%{image: image, duplicate_of_image: duplicate_of_image}), + do: blank?(duplicate_of_image.source_url) and blank?(image.source_url) + + def same_source?(%{image: image, duplicate_of_image: duplicate_of_image}), + do: to_string(duplicate_of_image.source_url) == to_string(image.source_url) + + def similar_source?(%{image: image, duplicate_of_image: duplicate_of_image}) do + host1 = URI.parse(image.source_url).host + host2 = URI.parse(duplicate_of_image.source_url).host + + host1 == host2 + end + + def source_on_target?(%{image: image, duplicate_of_image: duplicate_of_image}), + do: present?(duplicate_of_image.source_url) and blank?(image.source_url) + + def source_on_source?(%{image: image, duplicate_of_image: duplicate_of_image}), + do: blank?(duplicate_of_image.source_url) && present?(image.source_url) + + def same_artist_tags?(%{image: image, duplicate_of_image: duplicate_of_image}), + do: MapSet.equal?(artist_tags(image), artist_tags(duplicate_of_image)) + + def more_artist_tags_on_target?(%{image: image, duplicate_of_image: duplicate_of_image}), + do: proper_subset?(artist_tags(image), artist_tags(duplicate_of_image)) + + def more_artist_tags_on_source?(%{image: image, duplicate_of_image: duplicate_of_image}), + do: proper_subset?(artist_tags(duplicate_of_image), artist_tags(image)) + + def same_rating_tags?(%{image: image, duplicate_of_image: duplicate_of_image}), + do: MapSet.equal?(rating_tags(image), rating_tags(duplicate_of_image)) + + def target_is_edit?(%{duplicate_of_image: duplicate_of_image}), + do: edit?(duplicate_of_image) + + def source_is_edit?(%{image: image}), + do: edit?(image) + + def both_are_edits?(%{image: image, duplicate_of_image: duplicate_of_image}), + do: edit?(image) and edit?(duplicate_of_image) + + def target_is_alternate_version?(%{duplicate_of_image: duplicate_of_image}), + do: alternate_version?(duplicate_of_image) + + def source_is_alternate_version?(%{image: image}), + do: alternate_version?(image) + + def both_are_alternate_versions?(%{image: image, duplicate_of_image: duplicate_of_image}), + do: alternate_version?(image) and alternate_version?(duplicate_of_image) + + defp artist_tags(%{tags: tags}) do + tags + |> Enum.filter(& &1.namespace == "artist") + |> Enum.map(& &1.name) + |> MapSet.new() + end + + defp rating_tags(%{tags: tags}) do + tags + |> Enum.filter(& &1.category == "rating") + |> Enum.map(& &1.name) + |> MapSet.new() + end + + defp edit?(%{tags: tags}) do + tags + |> Enum.filter(& &1.name == "edit") + |> Enum.any?() + end + + defp alternate_version?(%{tags: tags}) do + tags + |> Enum.filter(& &1.name == "alternate version") + |> Enum.any?() + end + + defp blank?(nil), do: true + defp blank?(""), do: true + defp blank?(str) when is_binary(str), do: String.trim(str) == "" + defp blank?(_object), do: false + + defp present?(object), do: not blank?(object) + + defp proper_subset?(set1, set2), + do: MapSet.subset?(set1, set2) and not MapSet.equal?(set1, set2) +end diff --git a/test/philomena_web/controllers/duplicate_report_controller_test.exs b/test/philomena_web/controllers/duplicate_report_controller_test.exs new file mode 100644 index 00000000..6e8e59d5 --- /dev/null +++ b/test/philomena_web/controllers/duplicate_report_controller_test.exs @@ -0,0 +1,88 @@ +defmodule PhilomenaWeb.DuplicateReportControllerTest do + use PhilomenaWeb.ConnCase + + alias Philomena.DuplicateReports + + @create_attrs %{} + @update_attrs %{} + @invalid_attrs %{} + + def fixture(:duplicate_report) do + {:ok, duplicate_report} = DuplicateReports.create_duplicate_report(@create_attrs) + duplicate_report + end + + describe "index" do + test "lists all duplicate_reports", %{conn: conn} do + conn = get(conn, Routes.duplicate_report_path(conn, :index)) + assert html_response(conn, 200) =~ "Listing Duplicate reports" + end + end + + describe "new duplicate_report" do + test "renders form", %{conn: conn} do + conn = get(conn, Routes.duplicate_report_path(conn, :new)) + assert html_response(conn, 200) =~ "New Duplicate report" + end + end + + describe "create duplicate_report" do + test "redirects to show when data is valid", %{conn: conn} do + conn = post(conn, Routes.duplicate_report_path(conn, :create), duplicate_report: @create_attrs) + + assert %{id: id} = redirected_params(conn) + assert redirected_to(conn) == Routes.duplicate_report_path(conn, :show, id) + + conn = get(conn, Routes.duplicate_report_path(conn, :show, id)) + assert html_response(conn, 200) =~ "Show Duplicate report" + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, Routes.duplicate_report_path(conn, :create), duplicate_report: @invalid_attrs) + assert html_response(conn, 200) =~ "New Duplicate report" + end + end + + describe "edit duplicate_report" do + setup [:create_duplicate_report] + + test "renders form for editing chosen duplicate_report", %{conn: conn, duplicate_report: duplicate_report} do + conn = get(conn, Routes.duplicate_report_path(conn, :edit, duplicate_report)) + assert html_response(conn, 200) =~ "Edit Duplicate report" + end + end + + describe "update duplicate_report" do + setup [:create_duplicate_report] + + test "redirects when data is valid", %{conn: conn, duplicate_report: duplicate_report} do + conn = put(conn, Routes.duplicate_report_path(conn, :update, duplicate_report), duplicate_report: @update_attrs) + assert redirected_to(conn) == Routes.duplicate_report_path(conn, :show, duplicate_report) + + conn = get(conn, Routes.duplicate_report_path(conn, :show, duplicate_report)) + assert html_response(conn, 200) + end + + test "renders errors when data is invalid", %{conn: conn, duplicate_report: duplicate_report} do + conn = put(conn, Routes.duplicate_report_path(conn, :update, duplicate_report), duplicate_report: @invalid_attrs) + assert html_response(conn, 200) =~ "Edit Duplicate report" + end + end + + describe "delete duplicate_report" do + setup [:create_duplicate_report] + + test "deletes chosen duplicate_report", %{conn: conn, duplicate_report: duplicate_report} do + conn = delete(conn, Routes.duplicate_report_path(conn, :delete, duplicate_report)) + assert redirected_to(conn) == Routes.duplicate_report_path(conn, :index) + assert_error_sent 404, fn -> + get(conn, Routes.duplicate_report_path(conn, :show, duplicate_report)) + end + end + end + + defp create_duplicate_report(_) do + duplicate_report = fixture(:duplicate_report) + {:ok, duplicate_report: duplicate_report} + end +end