From d94a09c2d94b5c04787cc4c140c970343d1eca8e Mon Sep 17 00:00:00 2001 From: "byte[]" Date: Fri, 29 Nov 2019 21:33:15 -0500 Subject: [PATCH] add image navigation --- lib/philomena/image_navigator.ex | 171 ++++++++++++++++++ lib/philomena/image_scope.ex | 16 ++ lib/philomena/image_sorter.ex | 70 +++++++ .../controllers/image/navigate_controller.ex | 69 +++++++ .../controllers/search_controller.ex | 72 +------- .../controllers/tag_controller.ex | 46 ++++- lib/philomena_web/router.ex | 1 + .../templates/image/_image_box.html.slime | 3 +- .../templates/image/_image_meta.html.slime | 6 +- .../templates/image/index.html.slime | 15 +- .../templates/search/index.html.slime | 2 +- .../templates/tag/show.html.slime | 2 +- lib/philomena_web/views/image_view.ex | 2 + lib/philomena_web/views/search_view.ex | 2 + 14 files changed, 396 insertions(+), 81 deletions(-) create mode 100644 lib/philomena/image_navigator.ex create mode 100644 lib/philomena/image_scope.ex create mode 100644 lib/philomena/image_sorter.ex create mode 100644 lib/philomena_web/controllers/image/navigate_controller.ex diff --git a/lib/philomena/image_navigator.ex b/lib/philomena/image_navigator.ex new file mode 100644 index 00000000..68effae9 --- /dev/null +++ b/lib/philomena/image_navigator.ex @@ -0,0 +1,171 @@ +defmodule Philomena.ImageNavigator do + alias Philomena.ImageSorter + alias Philomena.Images.{Image, Elasticsearch} + alias Philomena.Repo + import Ecto.Query + + # We get consecutive images by finding all images greater than or less than + # the current image, and grabbing the FIRST one + @range_comparison_for_order %{ + asc: :gt, + desc: :lt + } + + # If we didn't reverse for prev, it would be the LAST image, which would + # make Elasticsearch choke on deep pagination + @order_for_dir %{ + next: %{"asc" => :asc, "desc" => :desc}, + prev: %{"asc" => :desc, "desc" => :asc} + } + + @range_map %{ + gt: :gte, + lt: :lte + } + + def find_consecutive(image, rel, params, compiled_query, compiled_filter) do + image_index = + Image + |> where(id: ^image.id) + |> preload(:gallery_interactions) + |> Repo.one() + |> Map.merge(empty_fields()) + |> Elasticsearch.as_json() + + sort_data = ImageSorter.parse_sort(params) + + {sorts, filters} = + sort_data.sorts + |> Enum.map(&extract_filters(&1, image_index, rel)) + |> Enum.unzip() + + sorts = sortify(sorts, image_index) + filters = filterify(filters, image_index) + + Image.search_records( + %{ + query: %{ + bool: %{ + must: List.flatten([compiled_query, sort_data.queries, filters]), + must_not: [ + compiled_filter, + %{term: %{hidden_from_users: true}} + ] + } + }, + sort: List.flatten(sorts) + }, + %{page_size: 1} + ) + |> Enum.to_list() + |> case do + [] -> image + [next_image] -> next_image + end + end + + defp extract_filters(%{"galleries.position" => term} = sort, image, rel) do + # Extract gallery ID and current position + gid = term["nested_filter"]["term"]["galleries.id"] + pos = Enum.find(image[:galleries], & &1.id == gid).position + + # Sort in the other direction if we are going backwards + sd = term["order"] + order = @order_for_dir[rel][sd] + term = %{term | "order" => order} + sort = %{sort | "galleries.position" => term} + + filter = gallery_range_filter(@range_comparison_for_order[order], pos) + + {[sort], [filter]} + end + + defp extract_filters(sort, image, rel) do + [{sf, sd}] = Enum.to_list(sort) + order = @order_for_dir[rel][sd] + sort = %{sort | sf => order} + + field = String.to_existing_atom(sf) + filter = range_filter(sf, @range_comparison_for_order[order], image[field]) + + cond do + sf in [:_random, :_score] -> + {[sort], []} + + true -> + {[sort], [filter]} + end + end + + defp sortify(sorts, _image) do + List.flatten(sorts) + end + + defp filterify(filters, image) do + filters = List.flatten(filters) + + filters = + filters + |> Enum.with_index() + |> Enum.map(fn + {filter, 0} -> filter.this + {filter, i} -> + filters_so_far = + filters + |> Enum.take(i) + |> Enum.map(& &1.for_next) + + %{ + bool: %{ + must: [filter.this | filters_so_far] + } + } + end) + + %{ + bool: %{ + should: filters, + must_not: %{term: %{id: image.id}} + } + } + end + + defp range_filter(sf, dir, val) do + %{ + this: %{range: %{sf => %{dir => parse_val(val)}}}, + next: %{range: %{sf => %{@range_map[dir] => parse_val(val)}}} + } + end + + defp gallery_range_filter(dir, val) do + %{ + this: %{ + nested: %{ + path: :galleries, + query: %{range: %{"galleries.position" => %{dir => val}}} + } + }, + next: %{ + nested: %{ + path: :galleries, + query: %{range: %{"galleries.position" => %{@range_map[dir] => val}}} + } + } + } + end + + defp empty_fields do + %{ + user: nil, + deleter: nil, + upvoters: [], + downvoters: [], + favers: [], + hiders: [], + tags: [] + } + end + + defp parse_val(%NaiveDateTime{} = value), do: NaiveDateTime.to_iso8601(value) + defp parse_val(value), do: value +end \ No newline at end of file diff --git a/lib/philomena/image_scope.ex b/lib/philomena/image_scope.ex new file mode 100644 index 00000000..b7f07885 --- /dev/null +++ b/lib/philomena/image_scope.ex @@ -0,0 +1,16 @@ +defmodule Philomena.ImageScope do + def scope(conn) do + [] + |> scope(conn, "q") + |> scope(conn, "sf") + |> scope(conn, "sd") + end + + defp scope(list, conn, key) do + case conn.params[key] do + nil -> list + "" -> list + val -> [{key, val} | list] + end + end +end \ No newline at end of file diff --git a/lib/philomena/image_sorter.ex b/lib/philomena/image_sorter.ex new file mode 100644 index 00000000..f52adcae --- /dev/null +++ b/lib/philomena/image_sorter.ex @@ -0,0 +1,70 @@ +defmodule Philomena.ImageSorter do + def parse_sort(params) do + sd = parse_sd(params) + + parse_sf(params, sd) + end + + defp parse_sd(%{"sd" => sd}) when sd in ~W(asc desc), do: sd + defp parse_sd(_params), do: "desc" + + defp parse_sf(%{"sf" => sf}, sd) when + sf in ~W(created_at updated_at first_seen_at width height score comment_count tag_count wilson_score _score) + do + %{queries: [], sorts: [%{sf => sd}]} + end + + defp parse_sf(%{"sf" => "random"}, sd) do + random_query(:rand.uniform(4_294_967_296), sd) + end + + defp parse_sf(%{"sf" => <<"random:", seed::binary>>}, sd) do + case Integer.parse(seed) do + {seed, _rest} -> + random_query(seed, sd) + + _ -> + random_query(:rand.uniform(4_294_967_296), sd) + end + end + + defp parse_sf(%{"sf" => <<"gallery_id:", gallery::binary>>}, sd) do + case Integer.parse(gallery) do + {gallery, _rest} -> + %{ + queries: [], + sorts: [%{ + "galleries.position" => %{ + order: sd, + nested_path: :galleries, + nested_filter: %{ + term: %{ + "galleries.id": gallery + } + } + } + }] + } + + _ -> + %{queries: [%{match_none: %{}}], sorts: []} + end + end + + defp parse_sf(_params, sd) do + %{queries: [], sorts: [%{"created_at" => sd}]} + end + + defp random_query(seed, sd) do + %{ + queries: [%{ + function_score: %{ + query: %{match_all: %{}}, + random_score: %{seed: seed}, + boost_mode: :replace + } + }], + sorts: [%{"_score" => sd}] + } + end +end \ No newline at end of file diff --git a/lib/philomena_web/controllers/image/navigate_controller.ex b/lib/philomena_web/controllers/image/navigate_controller.ex new file mode 100644 index 00000000..9327d711 --- /dev/null +++ b/lib/philomena_web/controllers/image/navigate_controller.ex @@ -0,0 +1,69 @@ +defmodule PhilomenaWeb.Image.NavigateController do + use PhilomenaWeb, :controller + + alias Philomena.Images.Image + alias Philomena.Images.Query + alias Philomena.ImageNavigator + + plug PhilomenaWeb.CanaryMapPlug, index: :show + plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true + + def index(conn, %{"rel" => rel} = params) when rel in ~W(prev next) do + image = conn.assigns.image + filter = conn.assigns.compiled_filter + rel = String.to_existing_atom(rel) + + next_image = ImageNavigator.find_consecutive(image, rel, params, compile_query(conn), filter) + scope = Philomena.ImageScope.scope(conn) + + conn + |> redirect(to: Routes.image_path(conn, :show, next_image, scope)) + end + + def index(conn, %{"rel" => "find"}) do + image = conn.assigns.image + filter = conn.assigns.compiled_filter + pagination = conn.assigns.pagination + + # Global find does not use the current search scope. + resp = + Image.search( + %{ + query: %{ + bool: %{ + must: %{ + range: %{id: %{gt: image.id}} + }, + must_not: [ + filter, + %{term: %{hidden_from_users: true}} + ] + } + }, + sort: %{created_at: :desc}, + size: 0 + } + ) + + page_num = page_for_offset(pagination.page_size, resp["hits"]["total"]) + + conn + |> redirect(to: Routes.image_path(conn, :index, page: page_num)) + end + + defp page_for_offset(_per_page, 0), do: 1 + defp page_for_offset(per_page, offset) do + ((offset + 1) / per_page) + |> Float.ceil() + |> trunc() + |> to_string() + end + + defp compile_query(conn) do + user = conn.assigns.current_user + + {:ok, query} = Query.compile(user, conn.params["q"] || "") + + query + end +end \ No newline at end of file diff --git a/lib/philomena_web/controllers/search_controller.ex b/lib/philomena_web/controllers/search_controller.ex index 4262dade..c6a94d5e 100644 --- a/lib/philomena_web/controllers/search_controller.ex +++ b/lib/philomena_web/controllers/search_controller.ex @@ -2,6 +2,7 @@ defmodule PhilomenaWeb.SearchController do use PhilomenaWeb, :controller alias Philomena.Images.{Image, Query} + alias Philomena.ImageSorter alias Philomena.Interactions import Ecto.Query @@ -9,16 +10,14 @@ defmodule PhilomenaWeb.SearchController do def index(conn, params) do filter = conn.assigns.compiled_filter user = conn.assigns.current_user + sort = ImageSorter.parse_sort(params) with {:ok, query} <- Query.compile(user, params["q"]) do - sd = parse_sd(params) - sf = parse_sf(params, sd) - images = Image.search_records( %{ - query: %{bool: %{must: [query | sf.query], must_not: [filter, %{term: %{hidden_from_users: true}}]}}, - sort: sf.sort + query: %{bool: %{must: [query | sort.queries], must_not: [filter, %{term: %{hidden_from_users: true}}]}}, + sort: sort.sorts }, conn.assigns.pagination, Image |> preload(:tags) @@ -40,67 +39,4 @@ defmodule PhilomenaWeb.SearchController do end end - defp parse_sd(%{"sd" => sd}) when sd in ~W(asc desc), - do: sd - defp parse_sd(_params), do: :desc - - defp parse_sf(%{"sf" => sf}, sd) when - sf in ~W(created_at updated_at first_seen_at width height score comment_count tag_count wilson_score _score) - do - %{query: [], sort: %{sf => sd}} - end - - defp parse_sf(%{"sf" => "random"}, sd) do - random_query(:rand.uniform(4_294_967_296), sd) - end - - defp parse_sf(%{"sf" => <<"random:", seed::binary>>}, sd) do - case Integer.parse(seed) do - {seed, _rest} -> - random_query(seed, sd) - - _ -> - random_query(:rand.uniform(4_294_967_296), sd) - end - end - - defp parse_sf(%{"sf" => <<"gallery_id:", gallery::binary>>}, sd) do - case Integer.parse(gallery) do - {gallery, _rest} -> - %{ - query: [], - sort: %{ - "galleries.position": %{ - order: sd, - nested_path: :galleries, - nested_filter: %{ - term: %{ - "galleries.id": gallery - } - } - } - } - } - - _ -> - %{query: [], sort: %{match_none: %{}}} - end - end - - defp parse_sf(_params, sd) do - %{query: [], sort: %{created_at: sd}} - end - - defp random_query(seed, sd) do - %{ - query: [%{ - function_score: %{ - query: %{match_all: %{}}, - random_score: %{seed: seed}, - boost_mode: :replace - } - }], - sort: %{_score: sd} - } - end end diff --git a/lib/philomena_web/controllers/tag_controller.ex b/lib/philomena_web/controllers/tag_controller.ex index d97c2618..12caa426 100644 --- a/lib/philomena_web/controllers/tag_controller.ex +++ b/lib/philomena_web/controllers/tag_controller.ex @@ -66,6 +66,50 @@ defmodule PhilomenaWeb.TagController do dnp_entries = Enum.zip(dnp_bodies, tag.dnp_entries) - render(conn, "show.html", tag: tag, body: body, dnp_entries: dnp_entries, interactions: interactions, images: images, layout_class: "layout--wide") + search_query = escape_name(tag) + + render( + conn, + "show.html", + tag: tag, + body: body, + search_query: search_query, + dnp_entries: dnp_entries, + interactions: interactions, + images: images, + layout_class: "layout--wide" + ) + end + + def escape_name(%{name: name}) do + name = + name + |> String.replace(~r/\s+/, " ") + |> String.trim() + |> String.downcase() + + cond do + String.contains?(name, "(") or String.contains?(name, ")") -> + # \ * ? " should be escaped, wrap in quotes so parser doesn't + # choke on parens. + name = + name + |> String.replace("\\", "\\\\") + |> String.replace("*", "\\*") + |> String.replace("?", "\\?") + |> String.replace("\"", "\\\"") + + "\"#{name}\"" + + true -> + # \ * ? - ! " all must be escaped. + name + |> String.replace(~r/\A-/, "\\-") + |> String.replace(~r/\A!/, "\\!") + |> String.replace("\\", "\\\\") + |> String.replace("*", "\\*") + |> String.replace("?", "\\?") + |> String.replace("\"", "\\\"") + end end end diff --git a/lib/philomena_web/router.ex b/lib/philomena_web/router.ex index 28736d53..65352504 100644 --- a/lib/philomena_web/router.ex +++ b/lib/philomena_web/router.ex @@ -99,6 +99,7 @@ defmodule PhilomenaWeb.Router do resources "/tag_changes", Image.TagChangeController, only: [:index] resources "/source_changes", Image.SourceChangeController, only: [:index] resources "/description", Image.DescriptionController, only: [:update], singleton: true + resources "/navigate", Image.NavigateController, only: [:index] end scope "/tags", Tag, as: :tag do resources "/autocomplete", AutocompleteController, only: [:show], singleton: true diff --git a/lib/philomena_web/templates/image/_image_box.html.slime b/lib/philomena_web/templates/image/_image_box.html.slime index f7332264..25c10d47 100644 --- a/lib/philomena_web/templates/image/_image_box.html.slime +++ b/lib/philomena_web/templates/image/_image_box.html.slime @@ -1,4 +1,5 @@ elixir: + link = assigns[:link] || Routes.image_path(@conn, :show, @image) size_class = case @size do :thumb -> @@ -34,4 +35,4 @@ elixir: a.interaction--hide href="#" rel="nofollow" data-image-id=@image.id i.fa.fa-eye-slash title='Hide' .media-box__content.flex.flex--centered.flex--center-distributed class=size_class - = render PhilomenaWeb.ImageView, "_image_container.html", image: @image, size: @size, conn: @conn \ No newline at end of file + = render PhilomenaWeb.ImageView, "_image_container.html", link: link, image: @image, size: @size, conn: @conn \ No newline at end of file diff --git a/lib/philomena_web/templates/image/_image_meta.html.slime b/lib/philomena_web/templates/image/_image_meta.html.slime index cc91a5f2..b0c30d9d 100644 --- a/lib/philomena_web/templates/image/_image_meta.html.slime +++ b/lib/philomena_web/templates/image/_image_meta.html.slime @@ -1,11 +1,11 @@ .block.block__header .flex.flex--wrap.image-metabar.center--layout id="image_meta_#{@image.id}" .stretched-mobile-links - a.js-prev href="/" title="Previous Image (j)" + a.js-prev href=Routes.image_navigate_path(@conn, :index, @image, [rel: "prev"] ++ scope(@conn)) title="Previous Image (j)" i.fa.fa-chevron-left - a.js-up href="/" title="Find this image in the global image list (i)" + a.js-up href=Routes.image_navigate_path(@conn, :index, @image, [rel: "find"] ++ scope(@conn)) title="Find this image in the global image list (i)" i.fa.fa-chevron-up - a.js-next href="/" title="Next Image (k)" + a.js-next href=Routes.image_navigate_path(@conn, :index, @image, [rel: "next"] ++ scope(@conn)) title="Next Image (k)" i.fa.fa-chevron-right a.js-rand href="/" title="Random (r)" i.fa.fa-random diff --git a/lib/philomena_web/templates/image/index.html.slime b/lib/philomena_web/templates/image/index.html.slime index f4113096..73274bd4 100644 --- a/lib/philomena_web/templates/image/index.html.slime +++ b/lib/philomena_web/templates/image/index.html.slime @@ -1,8 +1,11 @@ -- header = assigns[:header] || "" -- route = assigns[:route] || fn p -> Routes.image_path(@conn, :index, p) end -- params = assigns[:params] || [] -- pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @images, route: route, params: params -- info = render PhilomenaWeb.PaginationView, "_pagination_info.html", page: @images +elixir: + header = assigns[:header] || "" + params = assigns[:params] || assigns[:scope] || [] + scope = assigns[:scope] || [] + route = assigns[:route] || fn p -> Routes.image_path(@conn, :index, p) end + image_url = fn image -> Routes.image_path(@conn, :show, image, scope) end + pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @images, route: route, params: params + info = render PhilomenaWeb.PaginationView, "_pagination_info.html", page: @images .block#imagelist-container section.block__header.flex @@ -12,7 +15,7 @@ .block__content.js-resizable-media-container = for image <- @images do - = render PhilomenaWeb.ImageView, "_image_box.html", image: image, size: assigns[:size] || :thumb, conn: @conn + = render PhilomenaWeb.ImageView, "_image_box.html", image: image, link: image_url.(image), size: assigns[:size] || :thumb, conn: @conn .block__header.block__header--light.flex = pagination diff --git a/lib/philomena_web/templates/search/index.html.slime b/lib/philomena_web/templates/search/index.html.slime index c8a9eb84..2e84196b 100644 --- a/lib/philomena_web/templates/search/index.html.slime +++ b/lib/philomena_web/templates/search/index.html.slime @@ -1,6 +1,6 @@ = cond do - Enum.any?(@images) -> - = render PhilomenaWeb.ImageView, "index.html", conn: @conn, images: @images, route: fn p -> Routes.search_path(@conn, :index, p) end, params: [q: @search_query] + = render PhilomenaWeb.ImageView, "index.html", conn: @conn, images: @images, route: fn p -> Routes.search_path(@conn, :index, p) end, scope: [q: @conn.params["q"], sf: @conn.params["sf"], sd: @conn.params["sd"]] - assigns[:error] -> p ' Oops, there was an error evaluating your query: diff --git a/lib/philomena_web/templates/tag/show.html.slime b/lib/philomena_web/templates/tag/show.html.slime index 0a3f9503..210a405c 100644 --- a/lib/philomena_web/templates/tag/show.html.slime +++ b/lib/philomena_web/templates/tag/show.html.slime @@ -1,2 +1,2 @@ = render PhilomenaWeb.TagView, "_tag_info_row.html", tag: @tag, body: @body, dnp_entries: @dnp_entries, conn: @conn -= render PhilomenaWeb.ImageView, "index.html", conn: @conn, images: @images \ No newline at end of file += render PhilomenaWeb.ImageView, "index.html", conn: @conn, images: @images, scope: [q: @search_query] \ No newline at end of file diff --git a/lib/philomena_web/views/image_view.ex b/lib/philomena_web/views/image_view.ex index 3ddc5d04..b83b281b 100644 --- a/lib/philomena_web/views/image_view.ex +++ b/lib/philomena_web/views/image_view.ex @@ -81,6 +81,8 @@ defmodule PhilomenaWeb.ImageView do Tag.display_order(tags) end + def scope(conn), do: Philomena.ImageScope.scope(conn) + defp thumb_format("svg"), do: "png" defp thumb_format(format), do: format end diff --git a/lib/philomena_web/views/search_view.ex b/lib/philomena_web/views/search_view.ex index c82d11e5..e60028d1 100644 --- a/lib/philomena_web/views/search_view.ex +++ b/lib/philomena_web/views/search_view.ex @@ -1,3 +1,5 @@ defmodule PhilomenaWeb.SearchView do use PhilomenaWeb, :view + + def scope(conn), do: Philomena.ImageScope.scope(conn) end