add image navigation

This commit is contained in:
byte[] 2019-11-29 21:33:15 -05:00
parent e91154e3c3
commit d94a09c2d9
14 changed files with 396 additions and 81 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -2,6 +2,7 @@ defmodule PhilomenaWeb.SearchController do
use PhilomenaWeb, :controller use PhilomenaWeb, :controller
alias Philomena.Images.{Image, Query} alias Philomena.Images.{Image, Query}
alias Philomena.ImageSorter
alias Philomena.Interactions alias Philomena.Interactions
import Ecto.Query import Ecto.Query
@ -9,16 +10,14 @@ defmodule PhilomenaWeb.SearchController do
def index(conn, params) do def index(conn, params) do
filter = conn.assigns.compiled_filter filter = conn.assigns.compiled_filter
user = conn.assigns.current_user user = conn.assigns.current_user
sort = ImageSorter.parse_sort(params)
with {:ok, query} <- Query.compile(user, params["q"]) do with {:ok, query} <- Query.compile(user, params["q"]) do
sd = parse_sd(params)
sf = parse_sf(params, sd)
images = images =
Image.search_records( Image.search_records(
%{ %{
query: %{bool: %{must: [query | sf.query], must_not: [filter, %{term: %{hidden_from_users: true}}]}}, query: %{bool: %{must: [query | sort.queries], must_not: [filter, %{term: %{hidden_from_users: true}}]}},
sort: sf.sort sort: sort.sorts
}, },
conn.assigns.pagination, conn.assigns.pagination,
Image |> preload(:tags) Image |> preload(:tags)
@ -40,67 +39,4 @@ defmodule PhilomenaWeb.SearchController do
end end
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 end

View file

@ -66,6 +66,50 @@ defmodule PhilomenaWeb.TagController do
dnp_entries = dnp_entries =
Enum.zip(dnp_bodies, tag.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
end end

View file

@ -99,6 +99,7 @@ defmodule PhilomenaWeb.Router do
resources "/tag_changes", Image.TagChangeController, only: [:index] resources "/tag_changes", Image.TagChangeController, only: [:index]
resources "/source_changes", Image.SourceChangeController, only: [:index] resources "/source_changes", Image.SourceChangeController, only: [:index]
resources "/description", Image.DescriptionController, only: [:update], singleton: true resources "/description", Image.DescriptionController, only: [:update], singleton: true
resources "/navigate", Image.NavigateController, only: [:index]
end end
scope "/tags", Tag, as: :tag do scope "/tags", Tag, as: :tag do
resources "/autocomplete", AutocompleteController, only: [:show], singleton: true resources "/autocomplete", AutocompleteController, only: [:show], singleton: true

View file

@ -1,4 +1,5 @@
elixir: elixir:
link = assigns[:link] || Routes.image_path(@conn, :show, @image)
size_class = size_class =
case @size do case @size do
:thumb -> :thumb ->
@ -34,4 +35,4 @@ elixir:
a.interaction--hide href="#" rel="nofollow" data-image-id=@image.id a.interaction--hide href="#" rel="nofollow" data-image-id=@image.id
i.fa.fa-eye-slash title='Hide' i.fa.fa-eye-slash title='Hide'
.media-box__content.flex.flex--centered.flex--center-distributed class=size_class .media-box__content.flex.flex--centered.flex--center-distributed class=size_class
= render PhilomenaWeb.ImageView, "_image_container.html", image: @image, size: @size, conn: @conn = render PhilomenaWeb.ImageView, "_image_container.html", link: link, image: @image, size: @size, conn: @conn

View file

@ -1,11 +1,11 @@
.block.block__header .block.block__header
.flex.flex--wrap.image-metabar.center--layout id="image_meta_#{@image.id}" .flex.flex--wrap.image-metabar.center--layout id="image_meta_#{@image.id}"
.stretched-mobile-links .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 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 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 i.fa.fa-chevron-right
a.js-rand href="/" title="Random (r)" a.js-rand href="/" title="Random (r)"
i.fa.fa-random i.fa.fa-random

View file

@ -1,8 +1,11 @@
- header = assigns[:header] || "" elixir:
- route = assigns[:route] || fn p -> Routes.image_path(@conn, :index, p) end header = assigns[:header] || ""
- params = assigns[:params] || [] params = assigns[:params] || assigns[:scope] || []
- pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @images, route: route, params: params scope = assigns[:scope] || []
- info = render PhilomenaWeb.PaginationView, "_pagination_info.html", page: @images 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 .block#imagelist-container
section.block__header.flex section.block__header.flex
@ -12,7 +15,7 @@
.block__content.js-resizable-media-container .block__content.js-resizable-media-container
= for image <- @images do = 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 .block__header.block__header--light.flex
= pagination = pagination

View file

@ -1,6 +1,6 @@
= cond do = cond do
- Enum.any?(@images) -> - 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] -> - assigns[:error] ->
p p
' Oops, there was an error evaluating your query: ' Oops, there was an error evaluating your query:

View file

@ -1,2 +1,2 @@
= render PhilomenaWeb.TagView, "_tag_info_row.html", tag: @tag, body: @body, dnp_entries: @dnp_entries, conn: @conn = 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 = render PhilomenaWeb.ImageView, "index.html", conn: @conn, images: @images, scope: [q: @search_query]

View file

@ -81,6 +81,8 @@ defmodule PhilomenaWeb.ImageView do
Tag.display_order(tags) Tag.display_order(tags)
end end
def scope(conn), do: Philomena.ImageScope.scope(conn)
defp thumb_format("svg"), do: "png" defp thumb_format("svg"), do: "png"
defp thumb_format(format), do: format defp thumb_format(format), do: format
end end

View file

@ -1,3 +1,5 @@
defmodule PhilomenaWeb.SearchView do defmodule PhilomenaWeb.SearchView do
use PhilomenaWeb, :view use PhilomenaWeb, :view
def scope(conn), do: Philomena.ImageScope.scope(conn)
end end