From ba14cf4edd8619904ff07eb6e7d544bbc76b5699 Mon Sep 17 00:00:00 2001 From: ecly Date: Sun, 17 Jun 2018 01:10:27 +0200 Subject: [PATCH] added simple undo history feature, closes #6 --- assets/css/app.css | 21 +++- assets/js/app.js | 19 ++- assets/static/images/undo_button.png | Bin 0 -> 23315 bytes assets/static/images/undo_overlay.png | Bin 0 -> 4665 bytes config/prod.secret.example.exs | 3 +- lib/image_tagger.ex | 20 +++ lib/image_tagger/review_server.ex | 117 +++++++++++++++--- .../channels/reviewer_channel.ex | 27 ++-- .../templates/page/index.html.eex | 19 +-- 9 files changed, 187 insertions(+), 39 deletions(-) create mode 100644 assets/static/images/undo_button.png create mode 100644 assets/static/images/undo_overlay.png diff --git a/assets/css/app.css b/assets/css/app.css index 5e875c9..b79e57c 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -7,17 +7,28 @@ label { font-weight:normal; } +#body { + text-align: center; +} + +.tag_buttons { + border-style: solid; + border-width: 1px; + margin-left: 10px; + margin-right: 10px; + height: 10vh; +} + #buttons { display: table; margin: 0 auto; position: relative; } -.button { - height: 100px; - border-style: solid; - border-width: 1px; - margin: 10px; +#undo { + height: 10vh; + margin-top: 10px; + position: relative; } #images { diff --git a/assets/js/app.js b/assets/js/app.js index 2f2b3b5..3f649e3 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -36,6 +36,7 @@ class App { var $good_button = $("#good") var $bad_button = $("#bad") var $next_button = $("#next") + var $undo_button = $("#undo") var $images_left = $("#images_left") var $reviewed_count = $("#reviewed_count") var $online = $("#online") @@ -43,6 +44,7 @@ class App { var $overlay_image = $("#overlay_image") var good_overlay_image = "images/good_overlay.png" var bad_overlay_image = "images/banned_overlay.png" + var undo_overlay_image = "images/undo_overlay.png" socket.onOpen( ev => console.log("SOCKET OPEN", ev) ) socket.onError( ev => console.log("SOCKET ERROR", ev) ) @@ -70,10 +72,15 @@ class App { $reviewed_count.text(value); } + var decrement_reviewed = function() { + var current = parseInt($reviewed_count.text(), 10); + var new_value = current <= 0 ? 0 : current - 1; + $reviewed_count.text(new_value); + } + var review = function(rating) { var poll_next = $('#auto_next').is(":checked") chan.push("submit_review", {review:rating, auto_next:poll_next}); - increment_reviewed(); } var poll_image= function() { @@ -88,6 +95,8 @@ class App { $bad_button.click(); } else if (e.keyCode == 39 || e.keyCode == 78) { // right arrow or n $next_button.click(); + } else if (e.keyCode == 37 || e.keyCode == 85) { // left arrow or u + $undo_button.click(); } else { return true; } @@ -110,11 +119,19 @@ class App { $good_button.click(function() { show_overlay_image(good_overlay_image); review("good"); + increment_reviewed(); }); $bad_button.click(function() { show_overlay_image(bad_overlay_image); review("bad"); + increment_reviewed(); + }); + + $undo_button.click(function() { + show_overlay_image(undo_overlay_image); + chan.push("undo", {}) + decrement_reviewed(); }); $next_button.click(function() { poll_image(); }); diff --git a/assets/static/images/undo_button.png b/assets/static/images/undo_button.png new file mode 100644 index 0000000000000000000000000000000000000000..32665f27205bc9ac7e85091d2ff7531a9ddf74f6 GIT binary patch literal 23315 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 literal 0 HcmV?d00001 diff --git a/assets/static/images/undo_overlay.png b/assets/static/images/undo_overlay.png new file mode 100644 index 0000000000000000000000000000000000000000..9a171bc029b91b220c9fa47b81ec220c180851f7 GIT binary patch literal 4665 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 M!GdUBYf8HGf4L}8;{X5v literal 0 HcmV?d00001 diff --git a/config/prod.secret.example.exs b/config/prod.secret.example.exs index 3a7e6fa..f0e84e7 100644 --- a/config/prod.secret.example.exs +++ b/config/prod.secret.example.exs @@ -15,9 +15,10 @@ config :ex_aws, access_key_id: ["", :instance_role], secret_access_key: ["", :instance_role] +# The amount of reviews we allow the user to undo at most. +config :image_tagger, history_size: 5 config :image_tagger, update_interval_seconds: 1 config :image_tagger, bucket_name: "bucket" - config :image_tagger, image_folder: "to_review" # Various tags. The tag should atom should correspond diff --git a/lib/image_tagger.ex b/lib/image_tagger.ex index ca10ec3..4976f46 100644 --- a/lib/image_tagger.ex +++ b/lib/image_tagger.ex @@ -48,6 +48,26 @@ defmodule ImageTagger do ExAws.S3.presigned_url(config, :get, bucket, image) end + @doc """ + Undoes the last review associated with the given reviewer. + A result tuple is returned contanining a presigned_url of the + image for which the tag was undone if any reviews are in the history + of the given reviwer, otherwise an error is returned. + + ## Examples + iex> ImageTagger.undo_last_review("reviewer_id") + {:ok, "www.s3.amazon.com/some_key/some_image.png"} + iex> ImageTagger.undo_last_review("reviewer_id") + {:error, "no images in history for given reviewer"} + """ + def undo_last_review(reviewer) do + case ReviewServer.undo_last_review(reviewer) do + {:ok, image} -> get_public_url(image) + {:error, reason} -> {:error, reason} + end + end + + @doc """ Fetches an image to review for the given reviewer. The image is polled from the ImageServer and added diff --git a/lib/image_tagger/review_server.ex b/lib/image_tagger/review_server.ex index 75071a9..fef1167 100644 --- a/lib/image_tagger/review_server.ex +++ b/lib/image_tagger/review_server.ex @@ -3,7 +3,9 @@ defmodule ImageTagger.ReviewServer do Server keeping track of all the images that are currently being reviewed. - Implemented as a map of { => image} + Implemented as a map of %{ => {current, history}}, + where current is the path to the image currently being reviewed, + and history is a keyword list with the last X images of the form {image, tag}. """ alias ExAws alias ImageTagger.ImageServer @@ -45,7 +47,6 @@ defmodule ImageTagger.ReviewServer do move_image_to_folder(image, folder) end - @doc """ Adds an image to the ReviewServer signifying that it is currently being reviewed. If the Reviewer is already associated @@ -55,10 +56,16 @@ defmodule ImageTagger.ReviewServer do """ def handle_call({:add_image, reviewer, image}, _from, state) do if Map.has_key?(state, reviewer) do - :ok = ImageServer.add_image(state[reviewer]) - end + {current, history} = state[reviewer] - {:reply, :ok, Map.put(state, reviewer, image)} + if current != nil do + :ok = ImageServer.add_image(current) + end + + {:reply, :ok, Map.put(state, reviewer, {image, history})} + else + {:reply, :ok, Map.put(state, reviewer, {image, []})} + end end @doc """ @@ -71,18 +78,36 @@ defmodule ImageTagger.ReviewServer do Returns: :ok """ - def handle_call({:review_image, reviewer, review}, _from, state) do - if Map.has_key?(state, reviewer) do - image = state[reviewer] - archive_image(image, review) - {:reply, :ok, Map.delete(state, reviewer)} - else - {:reply, :ok, state} + def handle_call({:review_image, reviewer, tag}, _from, state) do + case Map.get(state, reviewer) do + nil -> + {:reply, :ok, state} + + {nil, history} -> + {:reply, :ok, state} + + {current, history} -> + max_history = Application.fetch_env!(:image_tagger, :history_size) + + review = {current, tag} + + # If history exceeds max_history, we archive the oldest image + # in the history. + if length(history) >= max_history do + [{oldest_img, oldest_tag} | rest] = history + archive_image(oldest_img, oldest_tag) + new_reviewer_value = {nil, rest ++ [review]} + {:reply, :ok, Map.put(state, reviewer, new_reviewer_value)} + else + new_reviewer_value = {nil, history ++ [review]} + {:reply, :ok, Map.put(state, reviewer, new_reviewer_value)} + end end end @doc """ - Returns the size of the state. + Returns the size of the state, in the form of the amount of reviewers + currently stored by the ReviewServer. """ def handle_call(:get_count, _from, state) do {:reply, map_size(state), state} @@ -90,10 +115,47 @@ defmodule ImageTagger.ReviewServer do @doc """ Returns the values of the state, meaning all the images - associated with the currently connected reviewers. + associated with the currently connected reviewers. This includes + both the image currently being reviewed by each reviewer and their history. """ def handle_call(:get_images, _from, state) do - {:reply, Map.values(state), state} + current_images = + state + |> Map.values() + |> Enum.map(&elem(&1, 0)) + |> Enum.filter(&(&1 != nil)) + + history_images = + state + |> Map.values() + |> Enum.flat_map(fn {_, history} -> Keyword.keys(history) end) + + {:reply, current_images ++ history_images, state} + end + + @doc """ + Returns the values of the state, meaning all the images + associated with the currently connected reviewers. This includes + both the image currently being reviewed by each reviewer and their history. + """ + def handle_call({:undo_last_review, reviewer}, _from, state) do + case Map.get(state, reviewer) do + nil -> + {:reply, {:error, "no reviewer with given id"}, state} + + {_, []} -> + {:reply, {:error, "no images in history for the given reviewer"}, state} + + {current, history} -> + if current != nil do + :ok = ImageServer.add_image(current) + end + + {undone_img, _tag} = List.last(history) + new_history = Enum.drop(history, -1) + new_state = Map.put(state, reviewer, {undone_img, new_history}) + {:reply, {:ok, undone_img}, new_state} + end end @doc """ @@ -103,7 +165,13 @@ defmodule ImageTagger.ReviewServer do """ def handle_cast({:remove_reviewer, reviewer}, state) do if Map.has_key?(state, reviewer) do - :ok = ImageServer.add_image(state[reviewer]) + {current, history} = state[reviewer] + + if current != nil do + :ok = ImageServer.add_image(current) + end + + Enum.each(history, fn {img, tag} -> archive_image(img, tag) end) end {:noreply, Map.delete(state, reviewer)} @@ -162,6 +230,23 @@ defmodule ImageTagger.ReviewServer do GenServer.call(__MODULE__, {:review_image, reviewer, review}) end + @doc """ + Adds a review for an image. + The image is removed from the ReviewServer and moved to + a folder according to the reivew. + + ## Examples + + iex> ImageTagger.ReviewServer.undo_last_review("some_user_id") + {:ok, "to_review/some_image.png"} + :ok + iex> ImageTagger.ReviewServer.undo_last_review("some_user_id") + {:error, "no images in history for given reviewer"} + """ + def undo_last_review(reviewer) do + GenServer.call(__MODULE__, {:undo_last_review, reviewer}) + end + @doc """ Associates a reviewer with an image. currently being reviewed. If the reviewer is currently diff --git a/lib/image_tagger_web/channels/reviewer_channel.ex b/lib/image_tagger_web/channels/reviewer_channel.ex index a1afedd..4cb7d85 100644 --- a/lib/image_tagger_web/channels/reviewer_channel.ex +++ b/lib/image_tagger_web/channels/reviewer_channel.ex @@ -28,18 +28,20 @@ defmodule ImageTaggerWeb.ReviewerChannel do {:noreply, socket} end + + defp push_image_url_to_socket(socket, url) do + count = ImageTagger.images_left() + online = ImageTagger.reviewers_online() + msg = %{"url" => url, "count" => count, "online" => online} + push(socket, @new_image_event, msg) + end + # Pushes an image associated with the given socket if one is available # in the ImageServer. Otherwise does nothing. defp try_push_image_to_socket(socket) do case ImageTagger.fetch_image_to_review(socket.assigns.id) do - {:ok, url} -> - count = ImageTagger.images_left() - online = ImageTagger.reviewers_online() - response = %{"url" => url, "count" => count, "online" => online} - push(socket, @new_image_event, response) - - _otherwise -> - {:noreply, socket} + {:ok, url} -> push_image_url_to_socket(socket, url) + _otherwise -> nil end end @@ -49,6 +51,15 @@ defmodule ImageTaggerWeb.ReviewerChannel do {:noreply, socket} end + @doc false + def handle_in("undo", _msg, socket) do + case ImageTagger.undo_last_review(socket.assigns.id) do + {:ok, url} -> push_image_url_to_socket(socket, url) + _otherwise -> nil + end + {:noreply, socket} + end + @doc false def handle_in("submit_review", %{"review" => review_string, "auto_next" => get_next}, socket) do # expected to be either :good or :bad diff --git a/lib/image_tagger_web/templates/page/index.html.eex b/lib/image_tagger_web/templates/page/index.html.eex index eba5581..4b1dc27 100644 --- a/lib/image_tagger_web/templates/page/index.html.eex +++ b/lib/image_tagger_web/templates/page/index.html.eex @@ -5,10 +5,12 @@ -

-
- - +
+
+ + +
+

@@ -17,10 +19,11 @@
-
- Emoji, up or h for non-cheat images
- Hammer, down or j for cheat images
+
+ Emoji, up or h for non-cheat image
+ Hammer, down or j for cheat image
Next, right or n for next image
+ Undo, left or u for previous image
@@ -29,7 +32,7 @@
-
+
1